From 09a9bb3400dadce70e9d54de148233a7ae9bcc35 Mon Sep 17 00:00:00 2001 From: Boris Pozdena Date: Fri, 21 Nov 2025 16:19:09 +0100 Subject: [PATCH] Enhance file transfer UI with timestamp display #97 --- src/main_window.py | 123 +++++++++++++++++++++++++++++++++- src/ui/list_item_widget.ui | 47 ++++++++++++- src/ui/ui_list_item_widget.py | 29 ++++++-- src/utils/utils.py | 26 +++++++ src/workers.py | 42 ++++++++++-- 5 files changed, 254 insertions(+), 13 deletions(-) diff --git a/src/main_window.py b/src/main_window.py index c55073e..65c3833 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -39,7 +39,7 @@ from options import ( version, ) -from utils.utils import humanize_file_size, shorten_path +from utils.utils import humanize_file_size, shorten_path, format_relative_time from workers import WorkerThread, MaintenanceWorker, TaskList, workers from gui_settings_window import gui_settings_window @@ -131,6 +131,11 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.profile_status_pages[profile].quit_gui_signal.connect(self.graceful_shutdown) self.stackedLayout.addWidget(self.profile_status_pages[profile]) + # Connect scroll events for timestamp updates + listWidget = self.profile_status_pages[profile].listWidget + scrollbar = listWidget.verticalScrollBar() + scrollbar.valueChanged.connect(self.on_list_scrolled) + self.verticalLayout_2.addLayout(self.stackedLayout) # Ensure all profiles in comboBox are aligned to center @@ -203,6 +208,18 @@ class MainWindow(QMainWindow, Ui_MainWindow): self.auto_sync.timeout.connect(self.autostart_monitor) self.auto_sync.start(1000) + # Timer to refresh relative times for completed transfers + self.refresh_relative_times = QTimer() + self.refresh_relative_times.setSingleShot(False) + self.refresh_relative_times.timeout.connect(self.update_relative_times) + self.refresh_relative_times.start(60000) # Update every 60 seconds + + # Debounce timer for scroll-triggered updates + self.scroll_update_timer = QTimer() + self.scroll_update_timer.setSingleShot(True) + self.scroll_update_timer.timeout.connect(self.update_relative_times) + self.scroll_update_timer.setInterval(200) # 200ms debounce delay + def mousePressEvent(self, event): if gui_settings.get("frameless_window") == "True": self.dragPos = event.globalPosition().toPoint() @@ -841,20 +858,48 @@ class MainWindow(QMainWindow, Ui_MainWindow): if file_operation == "Deleting": item_widget.set_label_1(f"Deleted from {parent_dir}") item_widget.set_label_2(f"") + # Store timestamp and display for deleted files + if "timestamp" in data and data["timestamp"]: + item_widget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + item_widget.set_timestamp(relative_time) + else: + item_widget.set_timestamp("") elif file_operation == "Moving": item_widget.set_label_1(f"Trashed from {parent_dir}") item_widget.set_label_2(f"") + # Store timestamp and display for moved files + if "timestamp" in data and data["timestamp"]: + item_widget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + item_widget.set_timestamp(relative_time) + else: + item_widget.set_timestamp("") elif transfer_complete and file_operation == "Uploading": shortened_path = shorten_path(relative_path_display, 32) item_widget.set_label_1(f"Uploaded from {shortened_path}") item_widget.set_label_2(f"{file_size_human}") + # Store timestamp and display in separate timestamp label + if "timestamp" in data and data["timestamp"]: + item_widget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + item_widget.set_timestamp(relative_time) + else: + item_widget.set_timestamp("") elif transfer_complete and file_operation == "Downloading": shortened_path = shorten_path(relative_path_display, 32) item_widget.set_label_1(f"Downloaded from {shortened_path}") item_widget.set_label_2(f"{file_size_human}") + # Store timestamp and display in separate timestamp label + if "timestamp" in data and data["timestamp"]: + item_widget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + item_widget.set_timestamp(relative_time) + else: + item_widget.set_timestamp("") elif file_operation == "Downloading": # Estimate final size of file before download completes @@ -881,20 +926,48 @@ class MainWindow(QMainWindow, Ui_MainWindow): if file_operation == "Deleting": myQCustomQWidget.set_label_1(f"Deleted from {parent_dir}") myQCustomQWidget.set_label_2(f"") + # Store timestamp and display for deleted files + if "timestamp" in data and data["timestamp"]: + myQCustomQWidget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + myQCustomQWidget.set_timestamp(relative_time) + else: + myQCustomQWidget.set_timestamp("") elif file_operation == "Moving": myQCustomQWidget.set_label_1(f"Trashed from {parent_dir}") myQCustomQWidget.set_label_2(f"") + # Store timestamp and display for moved files + if "timestamp" in data and data["timestamp"]: + myQCustomQWidget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + myQCustomQWidget.set_timestamp(relative_time) + else: + myQCustomQWidget.set_timestamp("") elif transfer_complete and file_operation == "Uploading": shortened_path = shorten_path(relative_path_display, 32) myQCustomQWidget.set_label_1(f"Uploaded from {shortened_path}") myQCustomQWidget.set_label_2(f"{file_size_human}") + # Store timestamp and display in separate timestamp label + if "timestamp" in data and data["timestamp"]: + myQCustomQWidget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + myQCustomQWidget.set_timestamp(relative_time) + else: + myQCustomQWidget.set_timestamp("") elif transfer_complete and file_operation == "Downloading": shortened_path = shorten_path(relative_path_display, 32) myQCustomQWidget.set_label_1(f"Downloaded from {shortened_path}") myQCustomQWidget.set_label_2(f"{file_size_human}") + # Store timestamp and display in separate timestamp label + if "timestamp" in data and data["timestamp"]: + myQCustomQWidget.set_completion_timestamp(data["timestamp"]) + relative_time = format_relative_time(data["timestamp"]) + myQCustomQWidget.set_timestamp(relative_time) + else: + myQCustomQWidget.set_timestamp("") elif file_operation == "Downloading": # Estimate final size of file before download completes @@ -926,6 +999,54 @@ class MainWindow(QMainWindow, Ui_MainWindow): if listWidget.count() > 10_000: listWidget.takeItem(listWidget.count() - 1) + def on_list_scrolled(self, value): + """ + Debounced scroll handler - triggers timestamp update after scrolling stops. + Called when scrollbar position changes (user scroll or automatic scroll from new items). + """ + # Restart the debounce timer - only triggers after 200ms of no scrolling + self.scroll_update_timer.start() + + def update_relative_times(self): + """ + Update relative time display only for visible items in the viewport. + Optimized to update only 10-20 visible items instead of all 10,000 items. + Called every 60 seconds by QTimer and when user scrolls the list. + """ + for profile in self.profile_status_pages: + listWidget = self.profile_status_pages[profile].listWidget + + # Get the visible viewport area + rect = listWidget.viewport().contentsRect() + top_index = listWidget.indexAt(rect.topLeft()) + + # Skip if no items are visible + if not top_index.isValid(): + continue + + # Get the bottom visible index + bottom_index = listWidget.indexAt(rect.bottomLeft()) + + # Handle case where bottom index is invalid (last item is visible) + if not bottom_index.isValid(): + bottom_row = listWidget.count() - 1 + else: + bottom_row = bottom_index.row() + + # Only iterate through visible rows (typically 10-20 items instead of 10,000) + for row in range(top_index.row(), bottom_row + 1): + item = listWidget.item(row) + if item is not None: + item_widget = listWidget.itemWidget(item) + if item_widget is not None: + # Get the completion timestamp + timestamp = item_widget.get_completion_timestamp() + + # If item has a timestamp, update the relative time in the timestamp label + if timestamp is not None: + relative_time = format_relative_time(timestamp) + item_widget.set_timestamp(relative_time) + def show_login(self, profile): # Show login window with QT WebEngine self.window1 = QWidget() diff --git a/src/ui/list_item_widget.ui b/src/ui/list_item_widget.ui index 7db301d..a039a85 100644 --- a/src/ui/list_item_widget.ui +++ b/src/ui/list_item_widget.ui @@ -67,11 +67,11 @@ - + - 340 + 270 16777215 @@ -85,6 +85,43 @@ FileName + + Qt::PlainText + + + + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + + 8 + 50 + + + + color: rgba(128, 128, 128, 200); + + + + + + Qt::AlignRight|Qt::AlignVCenter + + + Qt::PlainText + @@ -107,6 +144,12 @@ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + Qt::PlainText + + + false + diff --git a/src/ui/ui_list_item_widget.py b/src/ui/ui_list_item_widget.py index 50d5781..ecb850b 100644 --- a/src/ui/ui_list_item_widget.py +++ b/src/ui/ui_list_item_widget.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'list_item_widget.ui' ## -## Created by: Qt User Interface Compiler version 6.2.2 +## Created by: Qt User Interface Compiler version 6.10.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -35,7 +35,7 @@ class Ui_list_item_widget(object): self.toolButton.setMinimumSize(QSize(0, 0)) self.toolButton.setAutoFillBackground(False) icon = QIcon() - icon.addFile(u"../resources/images/icons8-clouds-48.png", QSize(), QIcon.Normal, QIcon.Off) + icon.addFile(u"../resources/images/icons8-clouds-48.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) self.toolButton.setIcon(icon) self.toolButton.setIconSize(QSize(30, 40)) @@ -43,29 +43,45 @@ class Ui_list_item_widget(object): self.ls_label_file_name = QLabel(list_item_widget) self.ls_label_file_name.setObjectName(u"ls_label_file_name") - self.ls_label_file_name.setMaximumSize(QSize(340, 16777215)) + self.ls_label_file_name.setMaximumSize(QSize(270, 16777215)) font = QFont() font.setPointSize(10) font.setBold(True) self.ls_label_file_name.setFont(font) + self.ls_label_file_name.setTextFormat(Qt.PlainText) - self.gridLayout.addWidget(self.ls_label_file_name, 0, 1, 1, 2) + self.gridLayout.addWidget(self.ls_label_file_name, 0, 1, 1, 1) + + self.ls_label_timestamp = QLabel(list_item_widget) + self.ls_label_timestamp.setObjectName(u"ls_label_timestamp") + self.ls_label_timestamp.setMinimumSize(QSize(70, 0)) + self.ls_label_timestamp.setMaximumSize(QSize(70, 16777215)) + font1 = QFont() + font1.setPointSize(8) + self.ls_label_timestamp.setFont(font1) + self.ls_label_timestamp.setStyleSheet(u"color: rgba(128, 128, 128, 200);") + self.ls_label_timestamp.setAlignment(Qt.AlignRight|Qt.AlignVCenter) + self.ls_label_timestamp.setTextFormat(Qt.PlainText) + + self.gridLayout.addWidget(self.ls_label_timestamp, 0, 2, 1, 1) self.ls_label_2 = QLabel(list_item_widget) self.ls_label_2.setObjectName(u"ls_label_2") - sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.ls_label_2.sizePolicy().hasHeightForWidth()) self.ls_label_2.setSizePolicy(sizePolicy) self.ls_label_2.setMaximumSize(QSize(170, 16777215)) self.ls_label_2.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) + self.ls_label_2.setTextFormat(Qt.PlainText) + self.ls_label_2.setWordWrap(False) self.gridLayout.addWidget(self.ls_label_2, 2, 2, 2, 1) self.ls_label_1 = QLabel(list_item_widget) self.ls_label_1.setObjectName(u"ls_label_1") - sizePolicy1 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) sizePolicy1.setHorizontalStretch(0) sizePolicy1.setVerticalStretch(0) sizePolicy1.setHeightForWidth(self.ls_label_1.sizePolicy().hasHeightForWidth()) @@ -92,6 +108,7 @@ class Ui_list_item_widget(object): list_item_widget.setWindowTitle(QCoreApplication.translate("list_item_widget", u"Form", None)) self.toolButton.setText(QCoreApplication.translate("list_item_widget", u"...", None)) self.ls_label_file_name.setText(QCoreApplication.translate("list_item_widget", u"FileName", None)) + self.ls_label_timestamp.setText("") self.ls_label_2.setText(QCoreApplication.translate("list_item_widget", u"9960KB of 9100KB", None)) self.ls_label_1.setText(QCoreApplication.translate("list_item_widget", u"Downloading/Uploading ", None)) # retranslateUi diff --git a/src/utils/utils.py b/src/utils/utils.py index 720808c..1c0d091 100644 --- a/src/utils/utils.py +++ b/src/utils/utils.py @@ -1,6 +1,7 @@ import os import subprocess import re +from datetime import datetime import logging # from logger import logger @@ -105,3 +106,28 @@ def config_client_bin_path() -> str: return "onedrive" else: return gui_settings.get("client_bin_path") + + +def format_relative_time(timestamp): + """ + Format a datetime timestamp into compact relative time string. + Examples: "now", "2m ago", "3h ago", "2d ago" + """ + if timestamp is None: + return "" + + now = datetime.now() + delta = now - timestamp + total_seconds = int(delta.total_seconds()) + + if total_seconds < 60: + return "now" + elif total_seconds < 3600: # Less than 1 hour + minutes = total_seconds // 60 + return f"{minutes}m ago" + elif total_seconds < 86400: # Less than 1 day + hours = total_seconds // 3600 + return f"{hours}h ago" + else: # 1 day or more + days = total_seconds // 86400 + return f"{days}d ago" diff --git a/src/workers.py b/src/workers.py index 1801d8e..af56277 100644 --- a/src/workers.py +++ b/src/workers.py @@ -1,6 +1,7 @@ -from PySide6.QtCore import QThread, Signal, QFileInfo +from PySide6.QtCore import QThread, Signal, QFileInfo, Qt from PySide6.QtWidgets import QWidget, QFileIconProvider +from PySide6.QtGui import QFontMetrics # Imports for main window. @@ -10,6 +11,7 @@ from ui.ui_list_item_widget import Ui_list_item_widget import re import time import subprocess +from datetime import datetime from global_config import save_global_config from options import ( @@ -260,7 +262,7 @@ class WorkerThread(QThread): self.profile_status["status_message"] = "Initializing the OneDrive API" self.update_profile_status.emit(self.profile_status, self.profile_name) - elif "Processing:" in stdout or "Number of items to download from Microsoft OneDrive" in stdout: + elif "Processing:" in stdout or "Number of items to download from Microsoft OneDrive" in stdout or "OneDrive Client requested to create" in stdout: items_left = re.match(r"^Processing\s([0-9]+)\sOneDrive\sitems", stdout) if items_left != None: self.profile_status["status_message"] = f"OneDrive is processing {items_left.group(1)} items..." @@ -300,6 +302,7 @@ class WorkerThread(QThread): "file_path": "unknown file name" if self.file_path is None else self.file_path.group(1), "progress": progress, "transfer_complete": transfer_complete, + "timestamp": datetime.now() if transfer_complete else None, } # Update file transfer list @@ -333,6 +336,7 @@ class WorkerThread(QThread): "file_path": file_path, "progress": progress, "transfer_complete": transfer_complete, + "timestamp": datetime.now() if transfer_complete else None, } logging.info(transfer_progress_new) @@ -648,6 +652,14 @@ class TaskList(QWidget, Ui_list_item_widget): # Set up the user interface from Designer. self.setupUi(self) + # Store completion timestamp for relative time display + self.completion_timestamp = None + + # Enable text eliding for ls_label_2 to prevent horizontal overflow + self.ls_label_2.setWordWrap(False) + self.ls_label_2.setSizePolicy(self.ls_label_2.sizePolicy().horizontalPolicy(), self.ls_label_2.sizePolicy().verticalPolicy()) + self.ls_label_2_max_width = 170 # Store max width for eliding + def set_icon(self, file_path): self.fileInfo = QFileInfo(file_path) self.iconProvider = QFileIconProvider() @@ -656,7 +668,10 @@ class TaskList(QWidget, Ui_list_item_widget): self.toolButton.setIcon(self.icon) def set_file_name(self, file_path): - self.ls_label_file_name.setText(file_path) + # Use font metrics to elide long filenames + font_metrics = QFontMetrics(self.ls_label_file_name.font()) + elided_text = font_metrics.elidedText(file_path, Qt.ElideMiddle, 270) + self.ls_label_file_name.setText(elided_text) def get_file_name(self): return self.ls_label_file_name.text() @@ -669,7 +684,18 @@ class TaskList(QWidget, Ui_list_item_widget): self.ls_label_1.setText(text) def set_label_2(self, text): - self.ls_label_2.setText(text) + # Use font metrics to elide text if it exceeds maximum width + font_metrics = QFontMetrics(self.ls_label_2.font()) + elided_text = font_metrics.elidedText(text, Qt.ElideRight, self.ls_label_2_max_width) + self.ls_label_2.setText(elided_text) + + def set_timestamp(self, text): + """Set the timestamp label text""" + self.ls_label_timestamp.setText(text) + + def get_timestamp_text(self): + """Get current timestamp label text""" + return self.ls_label_timestamp.text() def hide_progress_bar(self, transfer_status: bool): if transfer_status: @@ -677,5 +703,13 @@ class TaskList(QWidget, Ui_list_item_widget): else: self.ls_progressBar.show() + def set_completion_timestamp(self, timestamp): + """Store the completion timestamp for this transfer""" + self.completion_timestamp = timestamp + + def get_completion_timestamp(self): + """Get the completion timestamp for this transfer""" + return self.completion_timestamp + workers = {}