1# Copyright (C) 2015-2020 Damon Lynch <damonlynch@gmail.com>
2
3# This file is part of Rapid Photo Downloader.
4#
5# Rapid Photo Downloader is free software: you can redistribute it and/or
6# modify it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Rapid Photo Downloader is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Rapid Photo Downloader.  If not,
17# see <http://www.gnu.org/licenses/>.
18
19__author__ = 'Damon Lynch'
20__copyright__ = "Copyright 2015-2020, Damon Lynch"
21
22from collections import (namedtuple, defaultdict, deque, Counter)
23from operator import attrgetter
24import locale
25from datetime import datetime
26import logging
27from itertools import groupby
28import pickle
29from pprint import pprint
30from typing import Dict, List, Tuple, Set, Optional, DefaultDict
31
32import arrow.arrow
33from arrow.arrow import Arrow
34
35from PyQt5.QtCore import (
36    QAbstractTableModel, QModelIndex, Qt, QSize, QSizeF, QRect, QItemSelection, QItemSelectionModel,
37    QBuffer, QIODevice, pyqtSignal, pyqtSlot, QRectF, QPoint,
38)
39from PyQt5.QtWidgets import (
40    QTableView, QStyledItemDelegate, QSlider, QLabel, QVBoxLayout, QStyleOptionViewItem, QStyle,
41    QAbstractItemView, QWidget, QHBoxLayout, QSizePolicy, QSplitter, QScrollArea, QStackedWidget,
42    QToolButton, QAction
43)
44from PyQt5.QtGui import (
45    QPainter, QFontMetrics, QFont, QColor, QGuiApplication, QPixmap, QPalette, QMouseEvent, QIcon,
46    QFontMetricsF
47)
48
49from raphodo.constants import (
50    FileType, Align, proximity_time_steps, TemporalProximityState, fileTypeColor, CustomColors,
51    DarkGray, MediumGray, DoubleDarkGray
52)
53from raphodo.rpdfile import FileTypeCounter
54from raphodo.preferences import Preferences
55from raphodo.viewutils import (
56    ThumbnailDataForProximity, QFramedWidget, QFramedLabel, scaledIcon
57)
58from raphodo.timeutils import locale_time, strip_zero, make_long_date_format, strip_am, strip_pm
59from raphodo.utilities import runs
60from raphodo.constants import Roles
61
62ProximityRow = namedtuple(
63    'ProximityRow', 'year, month, weekday, day, proximity, new_file, tooltip_date_col0, '
64                    'tooltip_date_col1, tooltip_date_col2'
65)
66
67UidTime = namedtuple('UidTime', 'ctime, arrowtime, uid, previously_downloaded')
68
69
70def humanize_time_span(start: Arrow, end: Arrow,
71                       strip_leading_zero_from_time: bool=True,
72                       insert_cr_on_long_line: bool=False,
73                       long_format: bool=False) -> str:
74    r"""
75    Make times and time spans human readable.
76
77    To run the doc test, install language packs for Russian, German and Chinese
78    in addition to English. See details in doctest.
79
80    :param start: start time
81    :param end: end time
82    :param strip_leading_zero_from_time: strip all leading zeros
83    :param insert_cr_on_long_line: insert a carriage return on long
84     lines
85    :param long_format: if True, return result in long format
86    :return: tuple of time span to be read by humans, in short and long format
87
88    >>> locale.setlocale(locale.LC_ALL, ('en_US', 'utf-8'))
89    'en_US.UTF-8'
90    >>> start = arrow.Arrow(2015,11,3,9)
91    >>> end = start
92    >>> print(humanize_time_span(start, end))
93    9:00 AM
94    >>> print(humanize_time_span(start, end, long_format=True))
95    Nov 3 2015, 9:00 AM
96    >>> print(humanize_time_span(start, end, False))
97    09:00 AM
98    >>> print(humanize_time_span(start, end, False, long_format=True))
99    Nov 3 2015, 09:00 AM
100    >>> start = arrow.Arrow(2015,11,3,9,1,23)
101    >>> end = arrow.Arrow(2015,11,3,9,1,24)
102    >>> print(humanize_time_span(start, end))
103    9:01 AM
104    >>> print(humanize_time_span(start, end, long_format=True))
105    Nov 3 2015, 9:01 AM
106    >>> start = arrow.Arrow(2015,11,3,9)
107    >>> end = arrow.Arrow(2015,11,3,10)
108    >>> print(humanize_time_span(start, end))
109    9:00 - 10:00 AM
110    >>> print(humanize_time_span(start, end, long_format=True))
111    Nov 3 2015, 9:00 - 10:00 AM
112    >>> start = arrow.Arrow(2015,11,3,9)
113    >>> end = arrow.Arrow(2015,11,3,13)
114    >>> print(humanize_time_span(start, end))
115    9:00 AM - 1:00 PM
116    >>> print(humanize_time_span(start, end, long_format=True))
117    Nov 3 2015, 9:00 AM - 1:00 PM
118    >>> start = arrow.Arrow(2015,11,3,12)
119    >>> print(humanize_time_span(start, end))
120    12:00 - 1:00 PM
121    >>> print(humanize_time_span(start, end, long_format=True))
122    Nov 3 2015, 12:00 - 1:00 PM
123    >>> start = arrow.Arrow(2015,11,3,12, 59)
124    >>> print(humanize_time_span(start, end))
125    12:59 - 1:00 PM
126    >>> print(humanize_time_span(start, end, long_format=True))
127    Nov 3 2015, 12:59 - 1:00 PM
128    >>> start = arrow.Arrow(2015,10,31,11,55)
129    >>> end = arrow.Arrow(2015,11,2,15,15)
130    >>> print(humanize_time_span(start, end))
131    Oct 31, 11:55 AM - Nov 2, 3:15 PM
132    >>> print(humanize_time_span(start, end, long_format=True))
133    Oct 31 2015, 11:55 AM - Nov 2 2015, 3:15 PM
134    >>> start = arrow.Arrow(2014,10,31,11,55)
135    >>> print(humanize_time_span(start, end))
136    Oct 31 2014, 11:55 AM - Nov 2 2015, 3:15 PM
137    >>> print(humanize_time_span(start, end, long_format=True))
138    Oct 31 2014, 11:55 AM - Nov 2 2015, 3:15 PM
139    >>> print(humanize_time_span(start, end, False))
140    Oct 31 2014, 11:55 AM - Nov 2 2015, 03:15 PM
141    >>> print(humanize_time_span(start, end, False, long_format=True))
142    Oct 31 2014, 11:55 AM - Nov 2 2015, 03:15 PM
143    >>> print(humanize_time_span(start, end, False, True))
144    Oct 31 2014, 11:55 AM -
145    Nov 2 2015, 03:15 PM
146    >>> print(humanize_time_span(start, end, False, True, long_format=True))
147    Oct 31 2014, 11:55 AM - Nov 2 2015, 03:15 PM
148    >>> locale.setlocale(locale.LC_ALL, ('ru_RU', 'utf-8'))
149    'ru_RU.UTF-8'
150    >>> start = arrow.Arrow(2015,11,3,9)
151    >>> end = start
152    >>> print(humanize_time_span(start, end))
153    9:00
154    >>> start = arrow.Arrow(2015,11,3,13)
155    >>> end = start
156    >>> print(humanize_time_span(start, end))
157    13:00
158    >>> print(humanize_time_span(start, end, long_format=True))
159    ноя 3 2015, 13:00
160    >>> locale.setlocale(locale.LC_ALL, ('de_DE', 'utf-8'))
161    'de_DE.UTF-8'
162    >>> start = arrow.Arrow(2015,12,18,13,15)
163    >>> end = start
164    >>> print(humanize_time_span(start, end))
165    13:15
166    >>> print(humanize_time_span(start, end, long_format=True))
167    Dez 18 2015, 13:15
168    >>> end = start.shift(hours=1)
169    >>> print(humanize_time_span(start, end))
170    13:15 - 14:15
171    >>> locale.setlocale(locale.LC_ALL, ('zh_CN', 'utf-8'))
172    'zh_CN.UTF-8'
173    >>> start = arrow.Arrow(2015,12,18,19,59,33)
174    >>> end = start
175    >>> print(humanize_time_span(start, end))
176    下午 07时59分
177    >>> end = start.shift(hours=1)
178    >>> print(humanize_time_span(start, end))
179    07时59分 - 下午 08时59分
180    """
181
182    strip = strip_leading_zero_from_time
183
184    if start.floor('minute') == end.floor('minute'):
185        short_format = strip_zero(locale_time(start.datetime), strip)
186        if not long_format:
187            return short_format
188        else:
189            # Translators: for example Nov 3 2015, 11:25 AM
190            # Translators: %(variable)s represents Python code, not a plural of the term
191            # variable. You must keep the %(variable)s untranslated, or the program will
192            # crash.
193            return _('%(date)s, %(time)s') % dict(
194                date=make_long_date_format(start),
195                time=short_format
196            )
197
198    if start.floor('day') == end.floor('day'):
199        # both dates are on the same day
200        start_time = strip_zero(locale_time(start.datetime), strip)
201        end_time = strip_zero(locale_time(end.datetime), strip)
202
203        if (start.hour < 12 and end.hour < 12):
204            # both dates are in the same morning
205            start_time = strip_am(start_time)
206        elif (start.hour >= 12 and end.hour >= 12):
207            start_time = strip_pm(start_time)
208
209        # Translators: %(variable)s represents Python code, not a plural of the term
210        # variable. You must keep the %(variable)s untranslated, or the program will
211        # crash.
212        time_span = _('%(starttime)s - %(endtime)s') % dict(
213            starttime=start_time,
214            endtime=end_time
215        )
216        if not long_format:
217            # Translators: for example 9:00 AM - 3:55 PM
218            return time_span
219        else:
220            # Translators: for example Nov 3 2015, 11:25 AM
221            # Translators: %(variable)s represents Python code, not a plural of the term
222            # variable. You must keep the %(variable)s untranslated, or the program will
223            # crash.
224            return _('%(date)s, %(time)s') % dict(
225                date=make_long_date_format(start),
226                time=time_span
227            )
228
229    # The start and end dates are on a different day
230
231    # Translators: for example Nov 3 or Dec 31
232    # Translators: %(variable)s represents Python code, not a plural of the term
233    # variable. You must keep the %(variable)s untranslated, or the program will
234    # crash.
235    start_date = _('%(month)s %(numeric_day)s') % dict(
236        month=start.datetime.strftime('%b'),
237        numeric_day=start.format('D')
238    )
239    # Translators: %(variable)s represents Python code, not a plural of the term
240    # variable. You must keep the %(variable)s untranslated, or the program will
241    # crash.
242    end_date = _('%(month)s %(numeric_day)s') % dict(
243        month=end.datetime.strftime('%b'),
244        numeric_day=end.format('D')
245    )
246
247    if start.floor('year') != end.floor('year') or long_format:
248        # Translators: for example Nov 3 2015
249        # Translators: %(variable)s represents Python code, not a plural of the term
250        # variable. You must keep the %(variable)s untranslated, or the program will
251        # crash.
252        start_date = _('%(date)s %(year)s') % dict(date=start_date, year=start.year)
253        # Translators: %(variable)s represents Python code, not a plural of the term
254        # variable. You must keep the %(variable)s untranslated, or the program will
255        # crash.
256        end_date = _('%(date)s %(year)s') % dict(date=end_date, year=end.year)
257
258    # Translators: for example, Nov 3, 12:15 PM
259    # Translators: %(variable)s represents Python code, not a plural of the term
260    # variable. You must keep the %(variable)s untranslated, or the program will
261    # crash.
262    start_datetime = _('%(date)s, %(time)s') % dict(
263        date=start_date, time=strip_zero(locale_time(start.datetime), strip)
264    )
265    # Translators: %(variable)s represents Python code, not a plural of the term
266    # variable. You must keep the %(variable)s untranslated, or the program will
267    # crash.
268    end_datetime = _('%(date)s, %(time)s') % dict(
269        date=end_date, time=strip_zero(locale_time(end.datetime), strip)
270    )
271
272    if not insert_cr_on_long_line or long_format:
273        # Translators: for example, Nov 3, 12:15 PM - Nov 4, 1:00 AM
274        # Translators: %(variable)s represents Python code, not a plural of the term
275        # variable. You must keep the %(variable)s untranslated, or the program will
276        # crash.
277        return _('%(earlier_time)s - %(later_time)s') % dict(
278            earlier_time=start_datetime, later_time=end_datetime
279        )
280    else:
281        # Translators, for example:
282        # Nov 3 2012, 12:15 PM -
283        # Nov 4 2012, 1:00 AM
284        # (please keep the line break signified by \n)
285        # Translators: %(variable)s represents Python code, not a plural of the term
286        # variable. You must keep the %(variable)s untranslated, or the program will
287        # crash.
288        return _('%(earlier_time)s -\n%(later_time)s') % dict(
289            earlier_time=start_datetime, later_time=end_datetime
290        )
291
292FontKerning = namedtuple('FontKerning', 'font, kerning')
293
294
295def monthFont() -> FontKerning:
296    font = QFont()
297    kerning = 1.2
298    font.setPointSize(font.pointSize() - 2)
299    font.setLetterSpacing(QFont.PercentageSpacing, kerning * 100)
300    font.setStretch(QFont.SemiExpanded)
301    return FontKerning(font, kerning)
302
303
304def weekdayFont() -> QFont:
305    font = QFont()
306    font.setPointSize(font.pointSize() - 3)
307    return font
308
309
310def dayFont() -> QFont:
311    font = QFont()
312    font.setPointSize(font.pointSize() + 1)
313    return font
314
315
316def proximityFont() -> QFont:
317    font = QFont()  # type: QFont
318    font.setPointSize(font.pointSize() - 2)
319    return font
320
321
322def invalidRowFont() -> QFont:
323    font = QFont()
324    font.setPointSize(font.pointSize() - 3)
325    return font
326
327
328class ProximityDisplayValues:
329    """
330    Temporal Proximity cell sizes.
331
332    Calculated in different process to that of main window.
333    """
334
335    def __init__(self):
336        self.depth = None
337        self.row_heights = []  # type: List[int]
338        self.col_widths = None  # type: Optional[Tuple[int]]
339
340        # row : (width, height)
341        self.col0_sizes = {}  # type: Dict[int, Tuple[int, int]]
342        self.c2_alignment = {}  # type: Dict[int, Align]
343        self.c2_end_of_day = set()  # type: Set[int]
344        self.c2_end_of_month = set()  # type: Set[int]
345        self.c1_end_of_month = set()  # type: Set[int]
346
347        self.assign_fonts()
348
349        # Column 0 - month + year
350        self.col0_padding = 20.0
351        self.col0_center_space = 2.0
352        self.col0_center_space_half = 1.0
353
354        # Column 1 - weekday + day
355        self.col1_center_space = 2.0
356        self.col1_center_space_half = 1.0
357        self.col1_padding = 10.0
358        self.col1_v_padding = 50.0
359        self.col1_v_padding_top = self.col1_v_padding_bot = self.col1_v_padding / 2
360
361        self.calculate_max_col1_size()
362        self.day_proportion = self.max_day_height / self.max_col1_text_height
363        self.weekday_proportion = self.max_weekday_height / self.max_col1_text_height
364
365        # Column 2 - proximity value e.g. 1:00 - 1:45 PM
366        self.col2_new_file_dot = False
367        self.col2_new_file_dot_size = 4
368        self.col2_new_file_dot_radius = self.col2_new_file_dot_size / 2
369        self.col2_font_descent_adjust = self.proximityMetrics.descent() / 3
370        self.col2_font_height_half = self.proximityMetrics.height() / 2
371        self.col2_new_file_dot_left_margin = 6.0
372
373        if self.col2_new_file_dot:
374            self.col2_text_left_margin = (
375                self.col2_new_file_dot_left_margin * 2 + self.col2_new_file_dot_size
376            )
377        else:
378            self.col2_text_left_margin = 10.0
379        self.col2_right_margin = 10.0
380        self.col2_v_padding = 6.0
381        self.col2_v_padding_half = 3.0
382
383    def assign_fonts(self) -> None:
384        self.proximityFont = proximityFont()
385        self.proximityFontPrevious = QFont(self.proximityFont)
386        self.proximityFontPrevious.setItalic(True)
387        self.proximityMetrics = QFontMetricsF(self.proximityFont)
388        self.proximityMetricsPrevious = QFontMetricsF(self.proximityFontPrevious)
389        mf = monthFont()
390        self.monthFont = mf.font
391        self.month_kerning = mf.kerning
392        self.monthMetrics = QFontMetricsF(self.monthFont)
393        self.weekdayFont = weekdayFont()
394        self.dayFont = dayFont()
395        self.invalidRowFont = invalidRowFont()
396        self.invalidRowFontMetrics = QFontMetricsF(self.invalidRowFont)
397        self.invalidRowHeightMin = self.invalidRowFontMetrics.height() + \
398                                   self.proximityMetrics.height()
399
400    def prepare_for_pickle(self) -> None:
401        self.proximityFont = self.proximityMetrics = None
402        self.proximityFontPrevious = self.proximityMetricsPrevious = None
403        self.monthFont = self.monthMetrics = None
404        self.weekdayFont = None
405        self.dayFont = None
406        self.invalidRowFont = self.invalidRowFontMetrics = None
407
408    def get_month_size(self, month: str) -> QSizeF:
409        boundingRect = self.monthMetrics.boundingRect(month)  # type: QRectF
410        height = boundingRect.height()
411        width = boundingRect.width() * self.month_kerning
412        size = QSizeF(width, height)
413        return size
414
415    def get_month_text(self, month, year) -> str:
416        if self.depth == 3:
417            # Translators: %(variable)s represents Python code, not a plural of the term
418            # variable. You must keep the %(variable)s untranslated, or the program will
419            # crash.
420            return _('%(month)s  %(year)s') % dict(month=month.upper(), year=year)
421        else:
422            return month.upper()
423
424    def column0Size(self, year: str, month: str) -> QSizeF:
425        # Don't return a cell size for empty cells that have been
426        # merged into the cell with content.
427        month = self.get_month_text(month, year)
428        size = self.get_month_size(month)
429        # Height and width are reversed because of the rotation
430        size.transpose()
431        return QSizeF(size.width() + self.col0_padding, size.height() + self.col0_padding)
432
433    def calculate_max_col1_size(self) -> None:
434        """
435        Determine largest size for column 1 cells.
436
437        Column 1 cell sizes are fixed.
438        """
439
440        dayMetrics = QFontMetricsF(dayFont())
441        day_width = 0
442        day_height = 0
443        for day in range(10, 32):
444            rect = dayMetrics.boundingRect(str(day))
445            day_width = max(day_width, rect.width())
446            day_height = max(day_height, rect.height())
447
448        self.max_day_height = day_height
449        self.max_day_width = day_width
450
451        weekday_width = 0
452        weekday_height = 0
453        weekdayMetrics = QFontMetricsF(weekdayFont())
454        for i in range(1, 7):
455            dt = datetime(2015, 11, i)  # Year and month are totally irrelevant, only want day
456            weekday = dt.strftime('%a').upper()
457            rect = weekdayMetrics.boundingRect(str(weekday))
458            weekday_width = max(weekday_width, rect.width())
459            weekday_height = max(weekday_height, rect.height())
460
461        self.max_weekday_height = weekday_height
462        self.max_weekday_width = weekday_width
463        self.max_col1_text_height = weekday_height + day_height + self.col1_center_space
464        self.max_col1_text_width = max(weekday_width, day_width)
465        self.col1_width = self.max_col1_text_width + self.col1_padding
466        self.col1_height = self.max_col1_text_height
467
468    def get_proximity_size(self, text: str) -> QSizeF:
469        text = text.split('\n')
470        width = height = 0
471        for t in text:
472            boundingRect = self.proximityMetrics.boundingRect(t)  # type: QRectF
473            width = max(width, boundingRect.width())
474            height += boundingRect.height()
475        size = QSizeF(
476            width  + self.col2_text_left_margin + self.col2_right_margin,
477            height + self.col2_v_padding
478        )
479        return size
480
481    def calculate_row_sizes(self, rows: List[ProximityRow],
482                            spans: List[Tuple[int, int, int]],
483                            depth: int) -> None:
484        """
485        Calculate row height and column widths. The latter is trivial,
486        the former far more complex.
487
488        Assumptions:
489         * column 1 cell size is fixed
490
491        :param rows: list of row details
492        :param spans: list of which rows & columns are spanned
493        :param depth: table depth
494        """
495
496        self.depth = depth
497
498        # Phase 1: (1) identify minimal sizes for columns 0 and 2, and group the cells
499        #          (2) assign alignment to column 2 cells
500
501        spans_dict = {(row, column): row_span for column, row, row_span in spans}
502        next_span_start_c0 = next_span_start_c1 = 0
503
504        sizes = []  # type: List[Tuple[QSize, List[List[int]]]]
505        for row, value in enumerate(rows):
506            if next_span_start_c0 == row:
507                c0_size = self.column0Size(value.year, value.month)
508                self.col0_sizes[row] = (c0_size.width(), c0_size.height())
509                c0_children = []
510                sizes.append((c0_size, c0_children))
511                c0_span = spans_dict.get((row, 0), 1)
512                next_span_start_c0 = row + c0_span
513                self.c2_end_of_month.add(row + c0_span - 1)
514            if next_span_start_c1 == row:
515                c1_children = []
516                c0_children.append(c1_children)
517                c1_span = spans_dict.get((row, 1), 1)
518                next_span_start_c1 = row + c1_span
519
520                c2_span = spans_dict.get((row + c1_span - 1, 2))
521                if c1_span > 1:
522                    self.c2_alignment[row] = Align.bottom
523                    if c2_span is None:
524                        self.c2_alignment[row + c1_span - 1] = Align.top
525
526                if row + c1_span - 1 in self.c2_end_of_month:
527                    self.c1_end_of_month.add(row)
528
529                skip_c2_end_of_day = False
530                if c2_span:
531                    final_day_in_c2_span = row + c1_span - 2 + c2_span
532                    c1_span_in_c2_span_final_day = spans_dict.get((final_day_in_c2_span, 1))
533                    skip_c2_end_of_day = c1_span_in_c2_span_final_day is not None
534
535                if not skip_c2_end_of_day:
536                    self.c2_end_of_day.add(row + c1_span - 1)
537
538            minimal_col2_size = self.get_proximity_size(value.proximity)
539            c1_children.append(minimal_col2_size)
540
541        # Phase 2: determine column 2 cell sizes, and max widths
542
543        c0_max_width = 0
544        c2_max_width = 0
545        for c0, c0_children in sizes:
546            c0_height = c0.height()
547            c0_max_width = max(c0_max_width, c0.width())
548            c0_children_height = 0
549            for c1_children in c0_children:
550                c1_children_height = sum(c2.height() for c2 in c1_children)
551                c2_max_width = max(c2_max_width, max(c2.width() for c2 in c1_children))
552                extra = max(self.col1_height - c1_children_height, 0) / 2
553
554                # Assign in c1's v_padding to first and last child, and any extra
555                c2 = c1_children[0]  # type: QSizeF
556                c2.setHeight(c2.height() + self.col1_v_padding_top + extra)
557                c2 = c1_children[-1]  # type: QSizeF
558                c2.setHeight(c2.height() + self.col1_v_padding_bot + extra)
559
560                c1_children_height += self.col1_v_padding_top + self.col1_v_padding_bot + extra * 2
561                c0_children_height += c1_children_height
562
563            extra = max(c0_height - c0_children_height, 0) / 2
564            if extra:
565                c2 = c0_children[0][0]  # type: QSizeF
566                c2.setHeight(c2.height() + extra)
567                c2 = c0_children[-1][-1]  # type: QSizeF
568                c2.setHeight(c2.height() + extra)
569
570            heights = [c2.height() for c1_children in c0_children for c2 in c1_children]
571            self.row_heights.extend(heights)
572
573        self.col_widths = (c0_max_width, self.col1_width, c2_max_width)
574
575    def assign_color(self, dominant_file_type: FileType) -> None:
576        self.tableColor = fileTypeColor(dominant_file_type)
577        self.tableColorDarker = self.tableColor.darker(110)
578
579
580class MetaUid:
581    r"""
582    Stores unique ids for each table cell.
583
584    Used first when generating the proximity table, and then when
585    displaying tooltips containing thumbnails.
586
587    Operations are performed by tuple of (row, column) or simply
588    by column.
589
590
591    >>> m = MetaUid()
592    >>> m[(0 , 0)] = [b'0', b'1', b'2']
593    >>> print(m)
594    MetaUid(({0: 3}, {}, {}) ({0: [b'0', b'1', b'2']}, {}, {}))
595    >>> m[[0, 0]]
596    [b'0', b'1', b'2']
597    >>> m.trim()
598    >>> m[[0, 0]]
599    [b'0', b'2']
600    >>> m.no_uids((0, 0))
601    3
602    """
603
604    def __init__(self):
605        self._uids = tuple({} for i in (0, 1 ,2))  # type: Tuple[Dict[int, List[bytes, ...]]]
606        self._no_uids = tuple({} for i in (0, 1, 2))  # type: Tuple[Dict[int, int]]
607        self._col2_row_index = dict()  # type: Dict[bytes, int]
608
609    def __repr__(self):
610        return 'MetaUid(%r %r)' % (self._no_uids, self._uids)
611
612    def __setitem__(self, key: Tuple[int, int], uids: List[bytes]) -> None:
613        row, col = key
614        assert row not in self._uids[col]
615        self._uids[col][row] = uids
616        self._no_uids[col][row] = len(uids)
617        for uid in uids:
618            self._col2_row_index[uid] = row
619
620    def __getitem__(self, key: Tuple[int, int]) -> List[bytes]:
621        row, col = key
622        return self._uids[col][row]
623
624    def trim(self) -> None:
625        """
626        Remove unique ids unnecessary for table viewing.
627
628        Don't, however, remove ids in col 2, as they're useful, e.g.
629        when manually marking a file as previously downloaded
630        """
631
632        for col in (0, 1):
633            for row in self._uids[col]:
634                uids = self._uids[col][row]
635                if len(uids) > 1:
636                    self._uids[col][row] = [uids[0], uids[-1]]
637
638    def no_uids(self, key: Tuple[int, int]) -> int:
639        """
640        Number of unique ids the cell had before it was trimmed.
641        """
642
643        row, col = key
644        return self._no_uids[col][row]
645
646    def uids(self, column: int) -> Dict[int, List[bytes]]:
647        return self._uids[column]
648
649    def uid_to_col2_row(self, uid) -> int:
650        return self._col2_row_index[uid]
651
652    def validate_rows(self, no_rows) -> Tuple[int]:
653        """
654        Very simple validation test to see if all rows are present
655        in cols 2 or 1.
656
657        :param no_rows: number of rows to validate
658        :return: Tuple of missing rows
659        """
660        valid = []
661
662        col0, col1, col2 = self._uids
663        no_col0, no_col1, no_col2 = self._no_uids
664
665        for i in range(no_rows):
666            msg0 = ''
667            msg1 = ''
668            if i not in col2 and i not in col1:
669                    msg0 = '_uids'
670            if i not in no_col2 and i not in col1:
671                msg1 = '_no_uids'
672            if msg0 or msg1:
673                msg = ' and '.join((msg0, msg1))
674                logging.error("%s: row %s is missing in %s", self.__class__.__name__, i, msg)
675                valid.append(i)
676
677        return tuple(valid)
678
679
680class TemporalProximityGroups:
681    """
682    Generates values to be displayed in Timeline view.
683
684    The Timeline has 3 columns:
685
686    Col 0: the year and month
687    Col 1: the day of the month
688    C0l 3: the proximity groups
689    """
690
691    # @profile
692    def __init__(self, thumbnail_rows: List[ThumbnailDataForProximity],
693                 temporal_span: int = 3600):
694        self.rows = []  # type: List[ProximityRow]
695
696        self.invalid_rows = tuple()  # type: Tuple[int]
697
698        # Store uids for each table cell
699        self.uids = MetaUid()
700
701        self.file_types_in_cell = dict()  # type: Dict[Tuple[int, int], str]
702        times_by_proximity = defaultdict(list)  # type: DefaultDict[int, Arrow]
703
704        # The rows the user sees in column 2 can span more than one row of the Timeline.
705        # Each day always spans at least one row in the Timeline, possibly more.
706
707        # group_no: no days spanned
708        day_spans_by_proximity = dict()  # type: Dict[int, int]
709        # group_no: (
710        uids_by_day_in_proximity_group = dict()  # type: Dict[int, Tuple[Tuple[int, int, int], List[bytes]]]
711
712        # uid: (year, month, day)
713        year_month_day = dict()  # type: Dict[bytes, Tuple[int, int, int]]
714
715        # group_no: List[uid]
716        uids_by_proximity = defaultdict(list)  # type: Dict[int, List[bytes, ...]]
717        # Determine if proximity group contains any files have not been previously downloaded
718        new_files_by_proximity = defaultdict(set)  # type: Dict[int, Set[bool]]
719
720        # Text that will appear in column 2 -- they proximity groups
721        text_by_proximity = deque()
722
723        # (year, month, day): [uid, uid, ...]
724        self.day_groups = defaultdict(list)  # type: DefaultDict[Tuple[int, int, int], List[bytes]]
725        # (year, month): [uid, uid, ...]
726        self.month_groups = defaultdict(list)  # type: DefaultDict[Tuple[int, int], List[bytes]]
727        # year: [uid, uid, ...]
728        self.year_groups = defaultdict(list)  # type: DefaultDict[int, List[bytes]]
729
730        # How many columns the Timeline will display - don't display year when the only dates
731        # are from this year, for instance.
732        self._depth = None  # type: Optional[int]
733        # Compared to right now, does the Timeline contain an entry from the previous year?
734        self._previous_year = False
735        # Compared to right now, does the Timeline contain an entry from the previous month?
736        self._previous_month = False
737
738        # Tuple of (column, row, row_span):
739        self.spans = []  # type: List[Tuple[int, int, int]]
740        self.row_span_for_column_starts_at_row = {}  # type: Dict[Tuple[int, int], int]
741
742        # Associate Timeline cells with uids
743        # Timeline row: id
744        self.proximity_view_cell_id_col1 = {}  # type: Dict[int, int]
745        # Timeline row: id
746        self.proximity_view_cell_id_col2 = {}  # type: Dict[int, int]
747        # col1, col2, uid
748        self.col1_col2_uid = []   # type: List[Tuple[int, int, bytes]]
749
750        if len(thumbnail_rows) == 0:
751            return
752
753        file_types = (row.file_type for row in thumbnail_rows)
754        self.dominant_file_type = Counter(file_types).most_common()[0][0]
755
756        self.display_values = ProximityDisplayValues()
757
758        thumbnail_rows.sort(key=attrgetter('ctime'))
759
760        # Generate an arrow date time for every timestamp we have
761        uid_times = [
762            UidTime(
763                tr.ctime, arrow.get(tr.ctime).to('local'), tr.uid, tr.previously_downloaded
764            )
765            for tr in thumbnail_rows
766        ]
767
768        self.thumbnail_types = tuple(row.file_type for row in thumbnail_rows)
769
770        now = arrow.now().to('local')
771        current_year = now.year
772        current_month = now.month
773
774        # Phase 1: Associate unique ids with their year, month and day
775        for x in uid_times:
776            t = x.arrowtime  # type: Arrow
777            year = t.year
778            month = t.month
779            day = t.day
780
781            # Could use arrow.floor here, but it's extremely slow
782            self.day_groups[(year, month, day)].append(x.uid)
783            self.month_groups[(year, month)].append(x.uid)
784            self.year_groups[year].append(x.uid)
785            if year != current_year:
786                # the Timeline contains an entry from the previous year to now
787                self._previous_year = True
788            if month != current_month or self._previous_year:
789                # the Timeline contains an entry from the previous month to now
790                self._previous_month = True
791            # Remember this extracted value
792            year_month_day[x.uid] = year, month, day
793
794        # Phase 2: Identify the proximity groups
795        group_no = 0
796        prev = uid_times[0]
797
798        times_by_proximity[group_no].append(prev.arrowtime)
799        uids_by_proximity[group_no].append(prev.uid)
800        new_files_by_proximity[group_no].add(not prev.previously_downloaded)
801
802        if len(uid_times) > 1:
803            for current in uid_times[1:]:
804                ctime = current.ctime
805                if ctime - prev.ctime > temporal_span:
806                    group_no += 1
807                times_by_proximity[group_no].append(current.arrowtime)
808                uids_by_proximity[group_no].append(current.uid)
809                new_files_by_proximity[group_no].add(not current.previously_downloaded)
810                prev = current
811
812        # Phase 3: Generate the proximity group's text that will appear in
813        # the right-most column and its tooltips.
814
815        # Also calculate the days spanned by each proximity group.
816        # If the days spanned is greater than 1, meaning the number of calendar days
817        # in the proximity group is more than 1, then also keep a copy of the group
818        # where it is broken into separate calendar days
819
820        # The iteration order doesn't really matter here, so can get away with the
821        # potentially unsorted output of dict.items()
822        for group_no, group in times_by_proximity.items():
823            start = group[0]  # type: Arrow
824            end = group[-1]  # type: Arrow
825
826            # Generate the text
827            short_form = humanize_time_span(start, end, insert_cr_on_long_line=True)
828            long_form = humanize_time_span(start, end, long_format=True)
829            text_by_proximity.append((short_form, long_form))
830
831            # Calculate the number of calendar days spanned by this proximity group
832            # e.g. 2015-12-1 12:00 - 2015-12-2 15:00 = 2 days
833            if len(group) > 1:
834                span = len(list(Arrow.span_range('day', start, end)))
835                day_spans_by_proximity[group_no] = span
836                if span > 1:
837                    # break the proximity group members into calendar days
838                    uids_by_day_in_proximity_group[group_no] = tuple(
839                        (y_m_d, list(day))
840                        for y_m_d, day in groupby(
841                            uids_by_proximity[group_no], year_month_day.get
842                        )
843                    )
844            else:
845                # start == end
846                day_spans_by_proximity[group_no] = 1
847
848        # Phase 4: Generate the rows to be displayed in the Timeline
849
850        # Keep in mind, the rows the user sees in column 2 can span more than
851        # one calendar day. In such cases, column 1 will be associated with
852        # one or more Timeline rows, one or more of which may be visible only in
853        # column 1.
854
855        timeline_row = -1  # index into each row in the Timeline
856        thumbnail_index = 0 # index into the
857        self.prev_row_month = (0, 0)
858        self.prev_row_day = (0, 0, 0)
859
860        # Iterating through the groups in order is critical. Cannot use dict.items() here.
861        for group_no in range(len(day_spans_by_proximity)):
862
863            span = day_spans_by_proximity[group_no]
864
865            timeline_row += 1
866
867            proximity_group_times = times_by_proximity[group_no]
868            atime = proximity_group_times[0]  # type: Arrow
869            uid = uids_by_proximity[group_no][0]  # type: bytes
870            y_m_d = year_month_day[uid]
871
872            col2_text, tooltip_col2_text = text_by_proximity.popleft()
873            new_file = any(new_files_by_proximity[group_no])
874
875            self.rows.append(
876                self.make_row(
877                    atime=atime,
878                    col2_text=col2_text,
879                    new_file=new_file,
880                    y_m_d= y_m_d,
881                    timeline_row=timeline_row,
882                    thumbnail_index=thumbnail_index,
883                    tooltip_col2_text=tooltip_col2_text,
884                )
885            )
886
887            uids = uids_by_proximity[group_no]
888            self.uids[(timeline_row, 2)] = uids
889
890            # self.dump_row(group_no)
891
892            if span == 1:
893                thumbnail_index += len(proximity_group_times)
894                continue
895
896            thumbnail_index += len(uids_by_day_in_proximity_group[group_no][0])
897
898            # For any proximity groups that span more than one Timeline row because they span
899            # more than one calender day, add the day to the Timeline, with blank values
900            # for the proximity group (column 2).
901            i = 0
902            for y_m_d, day in uids_by_day_in_proximity_group[group_no][1:]:
903                i += 1
904
905                timeline_row += 1
906                thumbnail_index += len(uids_by_day_in_proximity_group[group_no][i])
907                atime = arrow.get(*y_m_d)
908
909                self.rows.append(
910                    self.make_row(
911                        atime=atime,
912                        col2_text='',
913                        new_file=new_file,
914                        y_m_d=y_m_d,
915                        timeline_row=timeline_row,
916                        thumbnail_index=1,
917                        tooltip_col2_text=''
918                    )
919                )
920                # self.dump_row(group_no)
921
922        # Phase 5: Determine the row spans for each column
923        column = -1
924        for c in (0, 2, 4):
925            column += 1
926            start_row = 0
927            for timeline_row_index, row in enumerate(self.rows):
928                if row[c]:
929                    row_count = timeline_row_index - start_row
930                    if row_count > 1:
931                        self.spans.append((column, start_row, row_count))
932                    start_row = timeline_row_index
933                self.row_span_for_column_starts_at_row[(timeline_row_index, column)] = start_row
934
935            if start_row != len(self.rows) - 1:
936                self.spans.append((column, start_row, len(self.rows) - start_row))
937                for timeline_row_index in range(start_row, len(self.rows)):
938                    self.row_span_for_column_starts_at_row[(timeline_row_index, column)] = start_row
939
940        assert len(self.row_span_for_column_starts_at_row) == len(self.rows) * 3
941
942        # Phase 6: Determine the height and width of each row
943        self.display_values.calculate_row_sizes(self.rows, self.spans, self.depth())
944
945        # Phase 7: Assign appropriate color to table
946        self.display_values.assign_color(self.dominant_file_type)
947
948        # Phase 8: associate proximity table cells with uids
949
950        uid_rows_c1 = {}
951        for proximity_view_cell_id, timeline_row_index in enumerate(self.uids.uids(1)):
952            self.proximity_view_cell_id_col1[timeline_row_index] = proximity_view_cell_id
953            uids = self.uids.uids(1)[timeline_row_index]
954            for uid in uids:
955                uid_rows_c1[uid] = proximity_view_cell_id
956
957        uid_rows_c2 = {}
958
959        for proximity_view_cell_id, timeline_row_index in enumerate(self.uids.uids(2)):
960            self.proximity_view_cell_id_col2[timeline_row_index] = proximity_view_cell_id
961            uids = self.uids.uids(2)[timeline_row_index]
962            for uid in uids:
963                uid_rows_c2[uid] = proximity_view_cell_id
964
965        assert len(uid_rows_c2) == len(uid_rows_c1) == len(thumbnail_rows)
966
967        self.col1_col2_uid = [
968            (uid_rows_c1[row.uid], uid_rows_c2[row.uid], row.uid) for row in thumbnail_rows
969        ]
970
971        # Assign depth before wiping values used to determine it
972        self.depth()
973        self.display_values.prepare_for_pickle()
974
975        # Reduce memory use before pickle. Can save about 100MB with
976        # when working with approximately 70,000 thumbnails.
977
978        self.uids.trim()
979
980        self.day_groups = None
981        self.month_groups = None
982        self.year_groups = None
983
984        self.thumbnail_types = None
985
986        self.invalid_rows = self.validate()
987        if len(self.invalid_rows):
988            logging.error('Timeline validation failed')
989        else:
990            logging.info('Timeline validation passed')
991
992    def make_file_types_in_cell_text(self, slice_start: int, slice_end: int) -> str:
993        c = FileTypeCounter(self.thumbnail_types[slice_start:slice_end])
994        return c.summarize_file_count()[0]
995
996    def make_row(self, atime: Arrow,
997                 col2_text: str,
998                 new_file: bool,
999                 y_m_d: Tuple[int, int, int],
1000                 timeline_row: int,
1001                 thumbnail_index: int,
1002                 tooltip_col2_text: str) -> ProximityRow:
1003
1004        atime_month = y_m_d[:2]
1005        if atime_month != self.prev_row_month:
1006            self.prev_row_month = atime_month
1007            month = atime.datetime.strftime('%B')
1008            year = atime.year
1009            uids = self.month_groups[atime_month]
1010            slice_end = thumbnail_index + len(uids)
1011            self.file_types_in_cell[(timeline_row, 0)] = self.make_file_types_in_cell_text(
1012                slice_start=thumbnail_index, slice_end=slice_end
1013            )
1014            self.uids[(timeline_row, 0)] = uids
1015        else:
1016            month = year = ''
1017
1018        if y_m_d != self.prev_row_day:
1019            self.prev_row_day = y_m_d
1020            numeric_day = atime.format('D')
1021            weekday = atime.datetime.strftime('%a')
1022
1023            self.uids[(timeline_row, 1)] = self.day_groups[y_m_d]
1024        else:
1025            weekday = numeric_day = ''
1026
1027        # Translators: %(variable)s represents Python code, not a plural of the term
1028        # variable. You must keep the %(variable)s untranslated, or the program will
1029        # crash.
1030        month_day = _('%(month)s %(numeric_day)s') % dict(
1031            month=atime.datetime.strftime('%b'),
1032            numeric_day=atime.format('D')
1033        )
1034        # Translators: for example Nov 2 2015
1035        # Translators: %(variable)s represents Python code, not a plural of the term
1036        # variable. You must keep the %(variable)s untranslated, or the program will
1037        # crash.
1038        tooltip_col1 = _('%(date)s %(year)s') % dict(date= month_day, year=atime.year)
1039        # Translators: for example Nov 2015
1040        # Translators: %(variable)s represents Python code, not a plural of the term
1041        # variable. You must keep the %(variable)s untranslated, or the program will
1042        # crash.
1043        tooltip_col0 = _('%(month)s %(year)s') % dict(
1044            month=atime.datetime.strftime('%b'),
1045            year=atime.year
1046        )
1047
1048        return ProximityRow(
1049            year=year, month=month, weekday=weekday, day=numeric_day, proximity=col2_text,
1050            new_file=new_file, tooltip_date_col0=tooltip_col0, tooltip_date_col1=tooltip_col1,
1051            tooltip_date_col2=tooltip_col2_text
1052        )
1053
1054    def __len__(self) -> int:
1055        return len(self.rows)
1056
1057    def dump_row(self, group_no, extra='') -> None:
1058        row = self.rows[-1]
1059        print(group_no, extra, row.day, row.proximity.replace('\n', ' '))
1060
1061    def __getitem__(self, row_number) -> ProximityRow:
1062        return self.rows[row_number]
1063
1064    def __setitem__(self, row_number, proximity_row: ProximityRow) -> None:
1065        self.rows[row_number] = proximity_row
1066
1067    def __iter__(self):
1068        return iter(self.rows)
1069
1070    def depth(self) -> int:
1071        if self._depth is None:
1072            if len(self.year_groups) > 1 or self._previous_year:
1073                self._depth = 3
1074            elif len(self.month_groups) > 1 or self._previous_month:
1075                self._depth = 2
1076            elif len(self.day_groups) > 1:
1077                self._depth = 1
1078            else:
1079                self._depth = 0
1080        return self._depth
1081
1082    def __repr__(self) -> str:
1083        return 'TemporalProximityGroups with {} rows and depth of {}'.format(
1084            len(self.rows), self.depth()
1085        )
1086
1087    def validate(self, thumbnailModel=None) -> Tuple[int]:
1088        """
1089        Partial validation of proximity values
1090        :return:
1091        """
1092
1093        return self.uids.validate_rows(len(self.rows))
1094
1095    def uid_to_row(self, uid: bytes) -> int:
1096        return self.uids.uid_to_col2_row(uid=uid)
1097
1098    def row_uids(self, row: int) -> List[bytes]:
1099        return self.uids[row, 2]
1100
1101
1102def base64_thumbnail(pixmap: QPixmap, size: QSize) -> str:
1103    """
1104    Convert image into format useful for HTML data URIs.
1105
1106    See https://css-tricks.com/data-uris/
1107
1108    :param pixmap: image to convert
1109    :param size: size to scale to
1110    :return: data in base 64 format
1111    """
1112
1113    pixmap = pixmap.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
1114    buffer = QBuffer()
1115    buffer.open(QIODevice.WriteOnly)
1116    # Quality 100 means uncompressed, which is faster.
1117    pixmap.save(buffer, "PNG", quality=100)
1118    return bytes(buffer.data().toBase64()).decode()
1119
1120
1121class TemporalProximityModel(QAbstractTableModel):
1122    tooltip_image_size = QSize(90, 90)  # FIXME high DPI?
1123
1124    def __init__(self, rapidApp, groups: TemporalProximityGroups=None, parent=None) -> None:
1125        super().__init__(parent)
1126        self.rapidApp = rapidApp
1127        self.groups = groups
1128
1129        self.show_debug = False
1130        logger = logging.getLogger()
1131        for handler in logger.handlers:
1132            # name set in iplogging.setup_main_process_logging()
1133            if handler.name == 'console':
1134                self.show_debug = handler.level <= logging.DEBUG
1135
1136        self.force_show_debug = False  # set to True to always display debug info in Timeline
1137
1138    def columnCount(self, parent=QModelIndex()) -> int:
1139        return 3
1140
1141    def rowCount(self, parent=QModelIndex()) -> int:
1142        if self.groups:
1143            return len(self.groups)
1144        else:
1145            return 0
1146
1147    def data(self, index: QModelIndex, role=Qt.DisplayRole):
1148        if not index.isValid():
1149            return None
1150
1151        row = index.row()
1152        if row >= len(self.groups) or row < 0:
1153            return None
1154
1155        column = index.column()
1156        if column < 0 or column > 3:
1157            return None
1158        proximity_row = self.groups[row]  # type: ProximityRow
1159
1160        if role == Qt.DisplayRole:
1161            invalid_row = self.show_debug and row in self.groups.invalid_rows
1162            invalid_rows = self.show_debug and len(self.groups.invalid_rows) > 0 or \
1163                self.force_show_debug
1164            if column == 0:
1165                return proximity_row.year, proximity_row.month
1166            elif column == 1:
1167                return proximity_row.weekday, proximity_row.day
1168            else:
1169                return proximity_row.proximity, proximity_row.new_file, invalid_row, invalid_rows
1170
1171        elif role == Roles.uids:
1172            prow = self.groups.row_span_for_column_starts_at_row[(row, 2)]
1173            uids = self.groups.uids.uids(2)[prow]
1174            return uids
1175
1176        elif role == Qt.ToolTipRole:
1177            thumbnails = self.rapidApp.thumbnailModel.thumbnails
1178
1179            try:
1180
1181                if column == 1:
1182                    uids = self.groups.uids.uids(1)[row]
1183                    length = self.groups.uids.no_uids((row, 1))
1184                    date = proximity_row.tooltip_date_col1
1185                    file_types= self.rapidApp.thumbnailModel.getTypeCountForProximityCell(
1186                        col1id=self.groups.proximity_view_cell_id_col1[row]
1187                    )
1188                elif column == 2:
1189                    prow = self.groups.row_span_for_column_starts_at_row[(row, 2)]
1190                    uids = self.groups.uids.uids(2)[prow]
1191                    length = self.groups.uids.no_uids((prow, 2))
1192                    date = proximity_row.tooltip_date_col2
1193                    file_types = self.rapidApp.thumbnailModel.getTypeCountForProximityCell(
1194                        col2id=self.groups.proximity_view_cell_id_col2[prow]
1195                    )
1196                else:
1197                    assert column == 0
1198                    uids = self.groups.uids.uids(0)[row]
1199                    length = self.groups.uids.no_uids((row, 0))
1200                    date = proximity_row.tooltip_date_col0
1201                    file_types = self.groups.file_types_in_cell[row, column]
1202
1203            except KeyError as e:
1204                logging.exception('Error in Timeline generation')
1205                self.debugDumpState()
1206                return None
1207
1208            pixmap = thumbnails[uids[0]]  # type: QPixmap
1209
1210            image = base64_thumbnail(pixmap, self.tooltip_image_size)
1211            html_image1 = '<img src="data:image/png;base64,{}">'.format(image)
1212
1213            if length == 1:
1214                center = html_image2 = ''
1215            else:
1216                pixmap = thumbnails[uids[-1]]  # type: QPixmap
1217                image = base64_thumbnail(pixmap, self.tooltip_image_size)
1218                if length == 2:
1219                    center = '&nbsp;'
1220                else:
1221                    center = '&nbsp;&hellip;&nbsp;'
1222                html_image2 = '<img src="data:image/png;base64,{}">'.format(image)
1223
1224            tooltip = '{}<br>{} {} {}<br>{}'.format(
1225                date, html_image1, center, html_image2, file_types
1226            )
1227            return tooltip
1228
1229    def debugDumpState(self, selected_rows_col1: List[int]=None,
1230                       selected_rows_col2: List[int]=None) -> None:
1231
1232        thumbnailModel = self.rapidApp.thumbnailModel
1233        logging.debug('%r', self.groups)
1234
1235        # Print rows and values to the debugging output
1236        if len(self.groups) < 20:
1237            for row, prow in enumerate(self.groups.rows):
1238                logging.debug('Row %s', row)
1239                logging.debug('{} | {} | {}'.format(prow.year, prow.month, prow.day))
1240                for col in (0, 1, 2):
1241                    if row in self.groups.uids._uids[col]:
1242                        uids = self.groups.uids._uids[col][row]
1243                        files = ', '.join((thumbnailModel.rpd_files[uid].name for uid in uids))
1244                        logging.debug('Col {}: {}'.format(col, files))
1245
1246    def updatePreviouslyDownloaded(self, uids: List[bytes]) -> None:
1247        """
1248        Examine Timeline data to see if any Timeline rows should have their column 2
1249        formatting updated to reflect that there are no new files to be downloaded in
1250        that particular row
1251        :param uids: list of uids that have been manually marked as previously downloaded
1252        """
1253
1254        processed_rows = set()  # type: Set[int]
1255        rows_to_update = []
1256        for uid in uids:
1257            row = self.groups.uid_to_row(uid=uid)
1258            if row not in processed_rows:
1259                processed_rows.add(row)
1260                row_uids = self.groups.row_uids(row)
1261                logging.debug(
1262                    'Examining row %s to see if any have not been previously downloaded', row
1263                )
1264                if not self.rapidApp.thumbnailModel.anyFileNotPreviouslyDownloaded(uids=row_uids):
1265                    proximity_row = self.groups[row]  # type: ProximityRow
1266                    self.groups[row] = proximity_row._replace(new_file=False)
1267                    rows_to_update.append(row)
1268                    logging.debug('Row %s will be updated to show it has no new files')
1269
1270        if rows_to_update:
1271            for first, last in runs(rows_to_update):
1272                self.dataChanged.emit(self.index(first, 2), self.index(last, 2))
1273
1274
1275class TemporalProximityDelegate(QStyledItemDelegate):
1276    """
1277    Render table cell for Timeline.
1278
1279    All cell size calculations are done prior to rendering.
1280
1281    The table has 3 columns:
1282
1283     - Col 0: month & year (col will be hidden if all dates are in the current month)
1284     - Col 1: day e.g. 'Fri 16'
1285     - Col 2: time(s), e.g. '5:09 AM', or '4:09 - 5:27 PM'
1286    """
1287
1288    def __init__(self, parent=None) -> None:
1289        super().__init__(parent)
1290
1291        self.darkGray = QColor(DarkGray)
1292        self.darkerGray = self.darkGray.darker(140)
1293        # self.darkerGray = QColor(DoubleDarkGray)
1294        self.midGray = QColor(MediumGray)
1295
1296        # column 2 cell color is assigned in ProximityDisplayValues
1297
1298        palette = QGuiApplication.instance().palette()
1299        self.highlight = palette.highlight().color()
1300        self.darkerHighlight = self.highlight.darker(110)
1301        self.highlightText = palette.highlightedText().color()
1302
1303        self.newFileColor = QColor(CustomColors.color7.value)
1304
1305        self.dv = None  # type: ProximityDisplayValues
1306
1307    def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
1308        row = index.row()
1309        column = index.column()
1310        optionRectF = QRectF(option.rect)
1311
1312        if column == 0:
1313            # Month and year
1314            painter.save()
1315
1316            if option.state & QStyle.State_Selected:
1317                color = self.highlight
1318                textColor = self.highlightText
1319                barColor = self.darkerHighlight
1320            else:
1321                color = self.darkGray
1322                textColor = self.dv.tableColor
1323                barColor = self.darkerGray
1324            painter.fillRect(optionRectF, color)
1325            painter.setPen(textColor)
1326
1327            year, month = index.data()
1328
1329            month = self.dv.get_month_text(month, year)
1330
1331            x = optionRectF.x()
1332            y = optionRectF.y()
1333
1334            painter.setFont(self.dv.monthFont)
1335            painter.setPen(textColor)
1336
1337            # Set position in the cell
1338            painter.translate(x, y)
1339            # Rotate the coming text rendering
1340            painter.rotate(270.0)
1341
1342            # Translate positioning to reflect new rotation
1343            painter.translate(-1 * optionRectF.height(), 0)
1344            rect = QRectF(0, 0, optionRectF.height(), optionRectF.width())
1345
1346            painter.drawText(rect, Qt.AlignCenter, month)
1347
1348            painter.setPen(barColor)
1349            painter.drawLine(1, 0, 1, optionRectF.width())
1350
1351            painter.restore()
1352
1353        elif column == 1:
1354            # Day of the month
1355            painter.save()
1356
1357            if option.state & QStyle.State_Selected:
1358                color = self.highlight
1359                weekdayColor = self.highlightText
1360                dayColor = self.highlightText
1361                barColor = self.darkerHighlight
1362            else:
1363                color = self.darkGray
1364                weekdayColor = QColor(221, 221, 221)
1365                dayColor = QColor(Qt.white)
1366                barColor = self.darkerGray
1367
1368            painter.fillRect(optionRectF, color)
1369            weekday, day = index.data()
1370            weekday = weekday.upper()
1371            width = optionRectF.width()
1372            height = optionRectF.height()
1373
1374            painter.translate(optionRectF.x(), optionRectF.y())
1375            weekday_rect_bottom = (
1376                height / 2 - self.dv.max_col1_text_height * self.dv.day_proportion
1377            ) + self.dv.max_weekday_height
1378            weekdayRect = QRectF(0, 0, width, weekday_rect_bottom)
1379            day_rect_top = weekday_rect_bottom + self.dv.col1_center_space
1380            dayRect = QRectF(0, day_rect_top, width, height - day_rect_top)
1381
1382            painter.setFont(self.dv.weekdayFont)
1383            painter.setPen(weekdayColor)
1384            painter.drawText(weekdayRect, Qt.AlignHCenter | Qt.AlignBottom, weekday)
1385            painter.setFont(self.dv.dayFont)
1386            painter.setPen(dayColor)
1387            painter.drawText(dayRect, Qt.AlignHCenter | Qt.AlignTop, day)
1388
1389            if row in self.dv.c1_end_of_month:
1390                painter.setPen(barColor)
1391                painter.drawLine(
1392                    0, optionRectF.height() - 1, optionRectF.width(), optionRectF.height() - 1
1393                )
1394
1395            painter.restore()
1396
1397        elif column == 2:
1398            # Time during the day
1399            text, new_file, invalid_row, invalid_rows = index.data()
1400
1401            painter.save()
1402
1403            if invalid_row:
1404                color = self.darkGray
1405                textColor = QColor(Qt.white)
1406            elif option.state & QStyle.State_Selected:
1407                color = self.highlight
1408                # TODO take into account dark themes
1409                if new_file:
1410                    textColor = self.highlightText
1411                else:
1412                    textColor = self.darkGray
1413            else:
1414                color = self.dv.tableColor
1415                if new_file:
1416                    textColor = QColor(Qt.white)
1417                else:
1418                    textColor = self.darkGray
1419
1420            painter.fillRect(optionRectF, color)
1421
1422            align = self.dv.c2_alignment.get(row)
1423
1424            if new_file and self.dv.col2_new_file_dot:
1425                # Draw a small circle beside the date (currently unused)
1426                painter.setPen(self.newFileColor)
1427                painter.setRenderHint(QPainter.Antialiasing)
1428                painter.setBrush(self.newFileColor)
1429                rect = QRectF(
1430                    optionRectF.x(),
1431                    optionRectF.y(),
1432                    self.dv.col2_new_file_dot_size,
1433                    self.dv.col2_new_file_dot_size
1434                )
1435                if align is None:
1436                    height = optionRectF.height() / 2 - self.dv.col2_new_file_dot_radius - \
1437                             self.dv.col2_font_descent_adjust
1438                    rect.translate(self.dv.col2_new_file_dot_left_margin, height)
1439                elif align == Align.bottom:
1440                    height = (
1441                        optionRectF.height() - self.dv.col2_font_height_half -
1442                        self.dv.col2_font_descent_adjust - self.dv.col2_new_file_dot_size
1443                    )
1444                    rect.translate(self.dv.col2_new_file_dot_left_margin, height)
1445                else:
1446                    height = (
1447                        self.dv.col2_font_height_half - self.dv.col2_font_descent_adjust
1448                    )
1449                    rect.translate(self.dv.col2_new_file_dot_left_margin, height)
1450                painter.drawEllipse(rect)
1451
1452            rect = optionRectF.translated(self.dv.col2_text_left_margin, 0)
1453
1454            painter.setPen(textColor)
1455
1456            if invalid_rows:
1457                # Render the row
1458                invalidRightRect = QRectF(optionRectF)
1459                invalidRightRect.translate(-2, 1)
1460                painter.setFont(self.dv.invalidRowFont)
1461                painter.drawText(invalidRightRect, Qt.AlignRight | Qt.AlignTop, str(row))
1462                if align != Align.top and self.dv.invalidRowHeightMin < option.rect.height():
1463                    invalidLeftRect = QRectF(option.rect)
1464                    invalidLeftRect.translate(1, 1)
1465                    painter.drawText(invalidLeftRect, Qt.AlignLeft | Qt.AlignTop, 'Debug mode')
1466
1467            painter.setFont(self.dv.proximityFont)
1468
1469            if align is None:
1470                painter.drawText(rect, Qt.AlignLeft | Qt.AlignVCenter, text)
1471            elif align == Align.bottom:
1472                rect.setHeight(rect.height() - self.dv.col2_v_padding_half)
1473                painter.drawText(rect, Qt.AlignLeft | Qt.AlignBottom, text)
1474            else:
1475                rect.adjust(0, self.dv.col2_v_padding_half, 0, 0)
1476                painter.drawText(rect, Qt.AlignLeft | Qt.AlignTop, text)
1477
1478            if row in self.dv.c2_end_of_day:
1479                if option.state & QStyle.State_Selected:
1480                    painter.setPen(self.darkerHighlight)
1481                else:
1482                    painter.setPen(self.dv.tableColorDarker)
1483                painter.translate(optionRectF.x(), optionRectF.y())
1484                painter.drawLine(
1485                    0, optionRectF.height() - 1, self.dv.col_widths[2], optionRectF.height() - 1
1486                )
1487
1488            painter.restore()
1489        else:
1490            super().paint(painter, option, index)
1491
1492
1493class TemporalProximityView(QTableView):
1494
1495    proximitySelectionHasChanged = pyqtSignal()
1496
1497    def __init__(self, temporalProximityWidget: 'TemporalProximity', rapidApp) -> None:
1498        super().__init__()
1499        self.rapidApp = rapidApp
1500        self.temporalProximityWidget = temporalProximityWidget
1501        self.verticalHeader().setVisible(False)
1502        self.horizontalHeader().setVisible(False)
1503        # Calling code should set this value to something sensible
1504        self.setMinimumWidth(200)
1505        self.horizontalHeader().setStretchLastSection(True)
1506        self.setWordWrap(True)
1507        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
1508        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
1509        self.setShowGrid(False)
1510
1511    def _updateSelectionRowChildColumn2(self, row: int, parent_column: int,
1512                                        model: TemporalProximityModel) -> None:
1513        """
1514        Select cells in column 2, based on selections in column 0 or 1.
1515
1516        :param row: the row of the cell that has been selected
1517        :param parent_column: the column of the cell that has been
1518         selected
1519        :param model: the model the view operates on
1520        """
1521
1522        for parent_row in range(row, row + self.rowSpan(row, parent_column)):
1523            start_row = model.groups.row_span_for_column_starts_at_row[(parent_row, 2)]
1524            row_span = self.rowSpan(start_row, 2)
1525
1526            do_selection = False
1527            if row_span > 1:
1528                all_selected = True
1529                for r in range(start_row, start_row + row_span):
1530                    if not self.selectionModel().isSelected(model.index(r, 1)):
1531                        all_selected = False
1532                        break
1533                if all_selected:
1534                    do_selection = True
1535            else:
1536                do_selection = True
1537
1538            if do_selection:
1539                self.selectionModel().select(model.index(start_row, 2), QItemSelectionModel.Select)
1540                model.dataChanged.emit(model.index(start_row, 2), model.index(start_row, 2))
1541
1542    def _updateSelectionRowChildColumn1(self, row: int, model: TemporalProximityModel) -> None:
1543        """
1544        Select cells in column 1, based on selections in column 0.
1545
1546        :param row: the row of the cell that has been selected
1547        :param model: the model the view operates on
1548        """
1549
1550        for r in range(row, row + self.rowSpan(row, 0)):
1551            self.selectionModel().select(
1552                model.index(r, 1), QItemSelectionModel.Select
1553            )
1554        model.dataChanged.emit(model.index(row, 1), model.index(r, 1))
1555
1556    def _updateSelectionRowParent(self, row: int,
1557                                  parent_column: int,
1558                                  start_column: int,
1559                                  examined: set,
1560                                  model: TemporalProximityModel) -> None:
1561        """
1562        Select cells in column 0 or 1, based on selections in column 2.
1563
1564        :param row: the row of the cell that has been selected
1565        :param parent_column: the column in which to select cells
1566        :param start_column: the column of the cell that has been
1567         selected
1568        :param examined: cells that have already been analyzed to see
1569         if they should be selected or not
1570        :param model: the model the view operates on
1571        """
1572        start_row = model.groups.row_span_for_column_starts_at_row[(row, parent_column)]
1573        if (start_row, parent_column) not in examined:
1574            all_selected = True
1575            for r in range(start_row, start_row + self.rowSpan(row, parent_column)):
1576                if not self.selectionModel().isSelected(model.index(r, start_column)):
1577                    all_selected = False
1578                    break
1579            if all_selected:
1580                i = model.index(start_row, parent_column)
1581                self.selectionModel().select(i, QItemSelectionModel.Select)
1582                model.dataChanged.emit(i, i)
1583            examined.add((start_row, parent_column))
1584
1585    def updateSelection(self) -> None:
1586        """
1587        Modify user selection to include extra columns.
1588
1589        When the user is selecting table cells, need to mimic the
1590        behavior of
1591        setSelectionBehavior(QAbstractItemView.SelectRows)
1592        However in our case we need to select multiple rows, depending
1593        on the row spans in columns 0, 1 and 2. Column 2 is a special
1594        case.
1595        """
1596
1597        # auto_scroll = self.temporalProximityWidget.prefs.auto_scroll
1598        # if auto_scroll:
1599        #     self.temporalProximityWidget.setTimelineThumbnailAutoScroll(False)
1600
1601        self.selectionModel().blockSignals(True)
1602
1603        model = self.model()  # type: TemporalProximityModel
1604        examined = set()
1605
1606        for i in self.selectedIndexes():
1607            row = i.row()
1608            column = i.column()
1609            if column == 0:
1610                examined.add((row, column))
1611                self._updateSelectionRowChildColumn1(row, model)
1612                examined.add((row, 1))
1613                self._updateSelectionRowChildColumn2(row, 0, model)
1614                examined.add((row, 2))
1615            if column == 1:
1616                examined.add((row, column))
1617                self._updateSelectionRowChildColumn2(row, 1, model)
1618                self._updateSelectionRowParent(row, 0, 1, examined, model)
1619                examined.add((row, 2))
1620            if column == 2:
1621                for r in range(row, row + self.rowSpan(row, 2)):
1622                    for parent_column in (1, 0):
1623                        self._updateSelectionRowParent(r, parent_column, 2, examined, model)
1624
1625        self.selectionModel().blockSignals(False)
1626
1627        # if auto_scroll:
1628        #     self.temporalProximityWidget.setTimelineThumbnailAutoScroll(True)
1629
1630    @pyqtSlot(QMouseEvent)
1631    def mousePressEvent(self, event: QMouseEvent) -> None:
1632        """
1633        Checks to see if Timeline selection should be cleared.
1634
1635        Should be cleared if the cell clicked in already represents
1636        a selection that cannot be expanded or made smaller with the
1637        same click.
1638
1639        A click outside the selection represents a new selection,
1640        should proceed.
1641
1642        A click inside a selection, but one that creates a new, smaller
1643        selection, should also proceed.
1644
1645        :param event: the mouse click event
1646        """
1647
1648        do_selection = True
1649        do_selection_confirmed = False
1650        index = self.indexAt(event.pos())  # type: QModelIndex
1651        if index in self.selectedIndexes():
1652            clicked_column = index.column()
1653            clicked_row = index.row()
1654            row_span = self.rowSpan(clicked_row, clicked_column)
1655            for i in self.selectedIndexes():
1656                column = i.column()
1657                row = i.row()
1658                # Is any selected column to the left of clicked column?
1659                if column < clicked_column:
1660                    # Is the row outside the span of the clicked row?
1661                    if (row < clicked_row or
1662                            row + self.rowSpan(row, column) > clicked_row + row_span):
1663                        do_selection_confirmed = True
1664                        break
1665                # Is this the only selected row in the column selected?
1666                if ((row < clicked_row or row >= clicked_row + row_span) and column ==
1667                        clicked_column):
1668                    do_selection_confirmed = True
1669                    break
1670
1671            if not do_selection_confirmed:
1672                self.clearSelection()
1673                self.rapidApp.proximityButton.setHighlighted(False)
1674                do_selection = False
1675                thumbnailView = self.rapidApp.thumbnailView
1676                model = self.model()
1677                uids = model.data(index, Roles.uids)
1678                thumbnailView.scrollToUids(uids=uids)
1679
1680        if do_selection:
1681            self.temporalProximityWidget.block_update_device_display = True
1682            super().mousePressEvent(event)
1683
1684    @pyqtSlot(QMouseEvent)
1685    def mouseReleaseEvent(self, event: QMouseEvent) -> None:
1686        self.temporalProximityWidget.block_update_device_display = False
1687        self.proximitySelectionHasChanged.emit()
1688        super().mouseReleaseEvent(event)
1689
1690    @pyqtSlot(int)
1691    def scrollThumbnails(self, value) -> None:
1692        index = self.indexAt(QPoint(200, 0))  # type: QModelIndex
1693        if index.isValid():
1694            if self.selectedIndexes():
1695                # It's now possible to scroll the Timeline and there will be
1696                # no matching thumbnails to which to scroll to in the display,
1697                # because they are not being displayed. Hence this check:
1698                 if not index in self.selectedIndexes():
1699                     return
1700            thumbnailView = self.rapidApp.thumbnailView
1701            thumbnailView.setScrollTogether(False)
1702            model = self.model()
1703            uids = model.data(index, Roles.uids)
1704            thumbnailView.scrollToUids(uids=uids)
1705            thumbnailView.setScrollTogether(True)
1706
1707
1708class TemporalValuePicker(QWidget):
1709    """
1710    Simple composite widget of QSlider and QLabel
1711    """
1712
1713    # Emits number of minutes
1714    valueChanged =  pyqtSignal(int)
1715
1716    def __init__(self, minutes: int, parent=None) -> None:
1717        super().__init__(parent)
1718        self.slider = QSlider(Qt.Horizontal)
1719        self.slider.setTickPosition(QSlider.TicksBelow)
1720        self.slider.setToolTip(
1721            _(
1722                "The time elapsed between consecutive photos and videos that is used to build the "
1723                "Timeline"
1724            )
1725        )
1726        self.slider.setMaximum(len(proximity_time_steps) - 1)
1727        self.slider.setValue(proximity_time_steps.index(minutes))
1728
1729        self.display = QLabel()
1730        font = QFont()
1731        font.setPointSize(font.pointSize() - 2)
1732        self.display.setFont(font)
1733        self.display.setAlignment(Qt.AlignCenter)
1734
1735        # Determine maximum width of display label
1736        width = 0
1737        labelMetrics = QFontMetricsF(QFont())
1738        for m in range(len(proximity_time_steps)):
1739            boundingRect = labelMetrics.boundingRect(self.displayString(m))  # type: QRect
1740            width = max(width, boundingRect.width())
1741
1742        self.display.setFixedWidth(width + 6)
1743
1744        self.slider.valueChanged.connect(self.updateDisplay)
1745        self.slider.sliderPressed.connect(self.sliderPressed)
1746        self.slider.sliderReleased.connect(self.sliderReleased)
1747
1748        self.display.setText(self.displayString(self.slider.value()))
1749
1750        layout = QHBoxLayout()
1751        layout.setContentsMargins(0, 0, 0, 0)
1752        layout.setSpacing(QFontMetricsF(font).height() / 6)
1753        self.setLayout(layout)
1754        layout.addWidget(self.slider)
1755        layout.addWidget(self.display)
1756
1757    @pyqtSlot()
1758    def sliderPressed(self):
1759        self.pressed_value = self.slider.value()
1760
1761    @pyqtSlot()
1762    def sliderReleased(self):
1763        if self.pressed_value != self.slider.value():
1764            self.valueChanged.emit(proximity_time_steps[self.slider.value()])
1765
1766    @pyqtSlot(int)
1767    def updateDisplay(self, value: int) -> None:
1768        self.display.setText(self.displayString(value))
1769        if not self.slider.isSliderDown():
1770            self.valueChanged.emit(proximity_time_steps[value])
1771
1772    def displayString(self, index: int) -> str:
1773        minutes = proximity_time_steps[index]
1774        if minutes < 60:
1775            # Translators: e.g. "45m", which is short for 45 minutes.
1776            # Replace the very last character (after the d) with the correct
1777            # localized value, keeping everything else. In other words, change
1778            # only the m character.
1779            return _("%(minutes)dm") % dict(minutes=minutes)
1780        elif minutes == 90:
1781            # Translators: i.e. "1.5h", which is short for 1.5 hours.
1782            # Replace the entire string with the correct localized value
1783            return _('1.5h')
1784        else:
1785            # Translators: e.g. "5h", which is short for 5 hours.
1786            # Replace the very last character (after the d) with the correct localized value,
1787            # keeping everything else. In other words, change only the h character.
1788            return _('%(hours)dh') % dict(hours=minutes // 60)
1789
1790
1791class TemporalProximity(QWidget):
1792    """
1793    Displays Timeline and tracks its state.
1794
1795    Main widget to display and control Timeline.
1796    """
1797
1798    proximitySelectionHasChanged = pyqtSignal()
1799
1800    def __init__(self, rapidApp,
1801                 prefs: Preferences,
1802                 parent=None) -> None:
1803        """
1804        :param rapidApp: main application window
1805        :type rapidApp: RapidWindow
1806        :param prefs: program & user preferences
1807        :param parent: parent widget
1808        """
1809
1810        super().__init__(parent)
1811
1812        self.rapidApp = rapidApp
1813        self.thumbnailModel = rapidApp.thumbnailModel
1814        self.prefs = prefs
1815
1816        self.block_update_device_display = False
1817
1818        self.state = TemporalProximityState.empty
1819
1820        self.uids_manually_set_previously_downloaded = []  # type: List[bytes]
1821
1822        self.temporalProximityView = TemporalProximityView(self, rapidApp=rapidApp)
1823        self.temporalProximityModel = TemporalProximityModel(rapidApp=rapidApp)
1824        self.temporalProximityView.setModel(self.temporalProximityModel)
1825        self.temporalProximityDelegate = TemporalProximityDelegate()
1826        self.temporalProximityView.setItemDelegate(self.temporalProximityDelegate)
1827        self.temporalProximityView.selectionModel().selectionChanged.connect(
1828            self.proximitySelectionChanged
1829        )
1830
1831        self.temporalProximityView.setSizePolicy(
1832            QSizePolicy.Preferred, QSizePolicy.Expanding
1833        )
1834
1835        self.temporalValuePicker = TemporalValuePicker(self.prefs.get_proximity())
1836        self.temporalValuePicker.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
1837
1838        description = _(
1839            'The Timeline groups photos and videos based on how much time elapsed '
1840            'between consecutive shots. Use it to identify photos and videos taken at '
1841            'different periods in a single day or over consecutive days.'
1842        )
1843        adjust = _(
1844            'Use the slider (below) to adjust the time elapsed between consecutive shots '
1845            'that is used to build the Timeline.'
1846        )
1847        generation_pending = _("Timeline build pending...")
1848        generating = _("Timeline is building...")
1849        ctime_vs_mtime = _(
1850            "The Timeline needs to be rebuilt because the file "
1851            "modification time does not match the time a shot was taken for one or more shots"
1852            ".<br><br>The Timeline shows when shots were taken. The time a shot was taken is "
1853            "found in a photo or video's metadata. "
1854            "Reading the metadata is time consuming, so Rapid Photo Downloader avoids reading the "
1855            "metadata while scanning files. Instead it uses the time the file was last modified "
1856            "as a proxy for when the shot was taken. The time a shot was taken is confirmed when "
1857            "generating thumbnails or downloading, which is when the metadata is read."
1858        )
1859
1860        description = '<i>{}</i>'.format(description)
1861        generation_pending = '<i>{}</i>'.format(generation_pending)
1862        generating = '<i>{}</i>'.format(generating)
1863        adjust = '<i>{}</i>'.format(adjust)
1864        ctime_vs_mtime = '<i>{}</i>'.format(ctime_vs_mtime)
1865
1866        palette = QPalette()
1867        palette.setColor(QPalette.Window, palette.color(palette.Base))
1868
1869        # TODO assign this value from somewhere else - rapidApp.standard_spacing not yet defined
1870        margin = 6
1871
1872        self.description = QLabel(description)
1873        self.adjust = QLabel(adjust)
1874        self.generating = QLabel(generating)
1875        self.generationPending = QLabel(generation_pending)
1876        self.ctime_vs_mtime = QLabel(ctime_vs_mtime)
1877
1878        self.explanation = QWidget()
1879        layout = QVBoxLayout()
1880        border_width = QSplitter().lineWidth()
1881        layout.setContentsMargins(border_width, border_width, border_width, border_width)
1882        layout.setSpacing(0)
1883        self.explanation.setLayout(layout)
1884        layout.addWidget(self.description)
1885        layout.addWidget(self.adjust)
1886
1887        for label in (self.description, self.generationPending, self.generating, self.adjust,
1888                      self.ctime_vs_mtime):
1889            label.setMargin(margin)
1890            label.setWordWrap(True)
1891            label.setAutoFillBackground(True)
1892            label.setPalette(palette)
1893
1894        for label in (self.description, self.generationPending, self.generating,
1895                      self.ctime_vs_mtime):
1896            label.setAlignment(Qt.AlignTop)
1897            label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding)
1898        self.adjust.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
1899
1900        layout = QVBoxLayout()
1901        self.setLayout(layout)
1902        layout.setContentsMargins(0, 0, 0, 0)
1903
1904        self.stackedWidget = QStackedWidget()
1905
1906        for label in (self.explanation, self.generationPending, self.generating,
1907                      self.ctime_vs_mtime):
1908            scrollArea = QScrollArea()
1909            scrollArea.setWidgetResizable(True)
1910            scrollArea.setWidget(label)
1911            self.stackedWidget.addWidget(scrollArea)
1912
1913        self.stackedWidget.addWidget(self.temporalProximityView)
1914
1915        self.stack_index_for_state = {
1916            TemporalProximityState.empty: 0,
1917            TemporalProximityState.pending: 1,
1918            TemporalProximityState.generating: 2,
1919            TemporalProximityState.regenerate: 2,
1920            TemporalProximityState.ctime_rebuild: 3,
1921            TemporalProximityState.ctime_rebuild_proceed: 3,
1922            TemporalProximityState.generated: 4
1923        }
1924
1925        self.autoScrollButton = QToolButton(self)
1926        icon = scaledIcon(':/icons/link.svg', self.autoScrollButton.iconSize())
1927        self.autoScrollButton.setIcon(icon)
1928        self.autoScrollButton.setAutoRaise(True)
1929        self.autoScrollButton.setCheckable(True)
1930        self.autoScrollButton.setToolTip(
1931            _('Toggle synchronizing Timeline and thumbnail scrolling (Ctrl-T)')
1932        )
1933        self.autoScrollButton.setChecked(not self.prefs.auto_scroll)
1934        self.autoScrollAct = QAction(
1935            '', self, shortcut="Ctrl+T",
1936            triggered=self.autoScrollActed, icon=icon
1937        )
1938        self.autoScrollButton.addAction(self.autoScrollAct)
1939        style = "QToolButton {padding: 2px;} QToolButton::menu-indicator {image: none;}"
1940        self.autoScrollButton.setStyleSheet(style)
1941        self.autoScrollButton.clicked.connect(self.autoScrollClicked)
1942
1943        pickerLayout = QHBoxLayout()
1944        pickerLayout.setSpacing(0)
1945        pickerLayout.addWidget(self.temporalValuePicker)
1946        pickerLayout.addWidget(self.autoScrollButton)
1947
1948        layout.addWidget(self.stackedWidget)
1949        layout.addLayout(pickerLayout)
1950
1951        self.stackedWidget.setCurrentIndex(0)
1952
1953        self.temporalValuePicker.valueChanged.connect(self.temporalValueChanged)
1954        if self.prefs.auto_scroll:
1955            self.setTimelineThumbnailAutoScroll(self.prefs.auto_scroll)
1956
1957        self.suppress_auto_scroll_after_timeline_select = False
1958
1959    @pyqtSlot(QItemSelection, QItemSelection)
1960    def proximitySelectionChanged(self, current: QItemSelection, previous: QItemSelection) -> None:
1961        """
1962        Respond to user selections in Temporal Proximity Table.
1963
1964        User can select / deselect individual cells. Need to:
1965        1. Automatically update selection to include parent or child
1966           cells in some cases
1967        2. Filter display of thumbnails
1968        """
1969
1970        self.temporalProximityView.updateSelection()
1971
1972        groups = self.temporalProximityModel.groups
1973
1974        selected_rows_col2 = [
1975            i.row() for i in self.temporalProximityView.selectedIndexes() if i.column() == 2
1976        ]
1977        selected_rows_col1 = [
1978            i.row() for i in self.temporalProximityView.selectedIndexes()
1979            if i.column() == 1 and groups.row_span_for_column_starts_at_row[(i.row(), 2)]
1980               not in selected_rows_col2
1981        ]
1982
1983        try:
1984            selected_col1 = [groups.proximity_view_cell_id_col1[row] for row in selected_rows_col1]
1985            selected_col2 = [groups.proximity_view_cell_id_col2[row] for row in selected_rows_col2]
1986        except KeyError as e:
1987            logging.exception('Error in Timeline generation')
1988            self.temporalProximityModel.debugDumpState(selected_rows_col1, selected_rows_col2)
1989            return
1990
1991        # Filter display of thumbnails, or reset the filter if lists are empty
1992        self.thumbnailModel.setProximityGroupFilter(selected_col1, selected_col2)
1993
1994        self.rapidApp.proximityButton.setHighlighted(True)
1995
1996        if not self.block_update_device_display:
1997            self.proximitySelectionHasChanged.emit()
1998
1999        self.suppress_auto_scroll_after_timeline_select = True
2000
2001    def clearThumbnailDisplayFilter(self):
2002        self.thumbnailModel.setProximityGroupFilter([],[])
2003        self.rapidApp.proximityButton.setHighlighted(False)
2004
2005    def setState(self, state: TemporalProximityState) -> None:
2006        """
2007        Set the state of the temporal proximity view, updating the displayed message
2008        :param state: the new state
2009        """
2010
2011        if state == self.state:
2012            return
2013
2014        if state == TemporalProximityState.ctime_rebuild_proceed:
2015            if self.state == TemporalProximityState.ctime_rebuild:
2016                self.state = TemporalProximityState.ctime_rebuild_proceed
2017                logging.debug("Timeline is ready to be rebuilt after ctime change")
2018                return
2019            else:
2020                logging.error(
2021                    "Unexpected request to set Timeline state to %s because current state is %s",
2022                    state.name, self.state.name
2023                )
2024        elif self.state == TemporalProximityState.ctime_rebuild and state != \
2025                TemporalProximityState.empty:
2026            logging.debug(
2027                "Ignoring request to set timeline state to %s because current state is ctime "
2028                "rebuild", state.name
2029            )
2030            return
2031
2032        logging.debug("Updating Timeline state from %s to %s", self.state.name, state.name)
2033
2034        self.stackedWidget.setCurrentIndex(self.stack_index_for_state[state])
2035        self.clearThumbnailDisplayFilter()
2036        self.state = state
2037
2038    def setGroups(self, proximity_groups: TemporalProximityGroups) -> bool:
2039        """
2040        Display the Timeline using data from the generated proximity_groups
2041        :param proximity_groups: Timeline content and formatting hints
2042        :return: True if Timeline was updated, False if not updated due to
2043         current state
2044        """
2045
2046        if self.state == TemporalProximityState.regenerate:
2047            self.rapidApp.generateTemporalProximityTableData(
2048                reason="a change was made while it was already generating"
2049            )
2050            return False
2051        if self.state == TemporalProximityState.ctime_rebuild:
2052            return False
2053
2054        self.temporalProximityModel.groups = proximity_groups
2055
2056        depth = proximity_groups.depth()
2057        self.temporalProximityDelegate.depth = depth
2058        if depth in (0, 1):
2059            self.temporalProximityView.hideColumn(0)
2060        else:
2061            self.temporalProximityView.showColumn(0)
2062
2063        self.temporalProximityView.clearSpans()
2064        self.temporalProximityDelegate.row_span_for_column_starts_at_row = \
2065            proximity_groups.row_span_for_column_starts_at_row
2066        self.temporalProximityDelegate.dv = proximity_groups.display_values
2067        self.temporalProximityDelegate.dv.assign_fonts()
2068
2069        for column, row, row_span in proximity_groups.spans:
2070            self.temporalProximityView.setSpan(row, column, row_span, 1)
2071
2072        self.temporalProximityModel.endResetModel()
2073
2074        for idx, height in enumerate(proximity_groups.display_values.row_heights):
2075            self.temporalProximityView.setRowHeight(idx, height)
2076        for idx, width in enumerate(proximity_groups.display_values.col_widths):
2077            self.temporalProximityView.setColumnWidth(idx, width)
2078
2079        # Set the minimum width for the timeline to match the content
2080        # Width of each column
2081        if depth in (0, 1):
2082            min_width = sum(proximity_groups.display_values.col_widths[1:])
2083        else:
2084            min_width = sum(proximity_groups.display_values.col_widths)
2085        # Width of each scrollbar
2086        scrollbar_width = self.style().pixelMetric(QStyle.PM_ScrollBarExtent)
2087        # Width of frame - without it, the tableview will still be too small
2088        frame_width = QSplitter().lineWidth() * 2
2089        self.temporalProximityView.setMinimumWidth(min_width + scrollbar_width + frame_width)
2090
2091        self.setState(TemporalProximityState.generated)
2092
2093        # Has the user manually set any files as previously downloaded while the Timeline was
2094        # generating?
2095        if self.uids_manually_set_previously_downloaded:
2096            self.temporalProximityModel.updatePreviouslyDownloaded(
2097                uids=self.uids_manually_set_previously_downloaded
2098            )
2099            self.uids_manually_set_previously_downloaded = []
2100
2101        return True
2102
2103    @pyqtSlot(int)
2104    def temporalValueChanged(self, minutes: int) -> None:
2105        self.prefs.set_proximity(minutes=minutes)
2106        if self.state == TemporalProximityState.generated:
2107            self.setState(TemporalProximityState.generating)
2108            self.rapidApp.generateTemporalProximityTableData(
2109                reason="the duration between consecutive shots has changed")
2110        elif self.state == TemporalProximityState.generating:
2111            self.state = TemporalProximityState.regenerate
2112
2113    def previouslyDownloadedManuallySet(self, uids: List[bytes]) -> None:
2114        """
2115        Possibly update the formatting of the Timeline to reflect the user
2116        manually setting files to have been previously downloaded
2117        """
2118
2119        logging.debug(
2120            "Updating Timeline to reflect %s files manually set as previously downloaded",
2121            len(uids)
2122        )
2123        if self.state != TemporalProximityState.generated:
2124            self.uids_manually_set_previously_downloaded.extend(uids)
2125        else:
2126            self.temporalProximityModel.updatePreviouslyDownloaded(uids=uids)
2127
2128    def scrollToUid(self, uid: bytes) -> None:
2129        """
2130        Scroll to this uid in the Timeline.
2131
2132        :param uid: uid to scroll to
2133        """
2134
2135        if self.state == TemporalProximityState.generated:
2136            if self.suppress_auto_scroll_after_timeline_select:
2137                self.suppress_auto_scroll_after_timeline_select = False
2138            else:
2139                view = self.temporalProximityView
2140                model = self.temporalProximityModel
2141                row = model.groups.uid_to_row(uid=uid)
2142                index = model.index(row, 2)
2143                view.scrollTo(index, QAbstractItemView.PositionAtTop)
2144
2145    def setTimelineThumbnailAutoScroll(self, on: bool) -> None:
2146        """
2147        Turn on or off synchronized scrolling between thumbnails and Timeline
2148        :param on: whether to turn on or off
2149        """
2150
2151        self.setScrollTogether(on)
2152        self.rapidApp.thumbnailView.setScrollTogether(on)
2153
2154    def setScrollTogether(self, on: bool) -> None:
2155        """
2156        Turn on or off the linking of scrolling the Timeline with the Thumbnail display
2157        :param on: whether to turn on or off
2158        """
2159
2160        view = self.temporalProximityView
2161        if on:
2162            view.verticalScrollBar().valueChanged.connect(view.scrollThumbnails)
2163        else:
2164            view.verticalScrollBar().valueChanged.disconnect(view.scrollThumbnails)
2165
2166    @pyqtSlot(bool)
2167    def autoScrollClicked(self, checked: bool) -> None:
2168        self.prefs.auto_scroll = not checked
2169        self.setTimelineThumbnailAutoScroll(not checked)
2170
2171    @pyqtSlot(bool)
2172    def autoScrollActed(self, on: bool) -> None:
2173        self.autoScrollButton.animateClick()
2174