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 = ' ' 1220 else: 1221 center = ' … ' 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