From 7cc36fb3ed866f778e72eec4691949d6d95a6752 Mon Sep 17 00:00:00 2001 From: KeksNino Date: Fri, 13 Mar 2026 18:57:25 +0100 Subject: [PATCH 1/2] refactor (almost) entire gui to split into multiple files --- src/core/interface/dialogs/contextmenu.py | 149 ++++ src/core/interface/dialogs/downloadlist.py | 88 +++ src/core/interface/dialogs/downloadmodel.py | 136 ++++ .../interface/dialogs/elideditemdelegate.py | 22 + src/core/interface/dialogs/eventfilter.py | 35 + .../interface/dialogs/hoverrowdelegate.py | 19 + src/core/interface/dialogs/image.py | 29 + .../interface/dialogs/pauseresumedelegate.py | 86 +++ src/core/interface/dialogs/settings.py | 12 +- src/core/interface/dialogs/theme.py | 79 ++ .../interface/dialogs/trackerhoverdelegate.py | 14 + src/core/interface/dialogs/trackertable.py | 26 + src/core/interface/dialogs/update.py | 31 + src/core/interface/gui.py | 682 +----------------- src/core/utils/network/update_checker.py | 4 +- 15 files changed, 747 insertions(+), 665 deletions(-) create mode 100644 src/core/interface/dialogs/contextmenu.py create mode 100644 src/core/interface/dialogs/downloadlist.py create mode 100644 src/core/interface/dialogs/downloadmodel.py create mode 100644 src/core/interface/dialogs/elideditemdelegate.py create mode 100644 src/core/interface/dialogs/eventfilter.py create mode 100644 src/core/interface/dialogs/hoverrowdelegate.py create mode 100644 src/core/interface/dialogs/image.py create mode 100644 src/core/interface/dialogs/pauseresumedelegate.py create mode 100644 src/core/interface/dialogs/theme.py create mode 100644 src/core/interface/dialogs/trackerhoverdelegate.py create mode 100644 src/core/interface/dialogs/trackertable.py create mode 100644 src/core/interface/dialogs/update.py diff --git a/src/core/interface/dialogs/contextmenu.py b/src/core/interface/dialogs/contextmenu.py new file mode 100644 index 0000000..76ec91c --- /dev/null +++ b/src/core/interface/dialogs/contextmenu.py @@ -0,0 +1,149 @@ +from core.utils.logging.logs import consoleLog, remove_download_log +from PySide6.QtWidgets import QMessageBox +from core.utils.data.state import state +from PySide6.QtCore import Qt, QPoint +from PySide6 import QtWidgets +import subprocess +import platform +import time +import os + +class ContextMenu: + def __init__(self, main_window): + self.main_window = main_window + self.context_menu = QtWidgets.QMenu(main_window) + self.context_menu.addAction("Open Containing Folder", self.openFolderAction) + self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction) + self.context_menu.addAction("Remove from list", self.cancelDownloadAction) + self.context_menu.addAction("Delete File", self.deleteFileAction) + + main_window.downloadList.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + main_window.downloadList.customContextMenuRequested.connect(self._show_context_menu) + + @property + def downloadList(self): + return self.main_window.downloadList + + def _show_context_menu(self, pos: QPoint): + index = self.downloadList.indexAt(pos) + if index.isValid(): + self._context_menu_row = index.row() + self.context_menu.exec(self.downloadList.viewport().mapToGlobal(pos)) + + def openFolderAction(self): + if not hasattr(self, '_context_menu_row'): + return + row = self._context_menu_row + if row < 0 or row >= len(state.active_downloads): + return + magnet_link = list(state.active_downloads.keys())[row] + magnetdl = state.active_downloads[magnet_link] + download_path = magnetdl.save_path() + if download_path and os.path.exists(download_path): + if platform.system() == "Windows": + os.startfile(os.path.normpath(download_path)) + elif platform.system() == "Linux": + subprocess.Popen(["xdg-open", download_path]) + elif platform.system() == "Darwin": + subprocess.Popen(["open", download_path]) + + def copyMagnetURIAction(self): + if not hasattr(self, '_context_menu_row'): + return + row = self._context_menu_row + if row < 0 or row >= len(state.active_downloads): + return + magnet_link = list(state.active_downloads.keys())[row] + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(magnet_link) + + def cancelDownloadAction(self): + if not hasattr(self, '_context_menu_row'): + return + row = self._context_menu_row + with state.downloads_lock: + if row < 0 or row >= len(state.active_downloads): + return + magnet_link = list(state.active_downloads.keys())[row] + magnetdl = state.active_downloads[magnet_link] + + # Cache the name BEFORE removing from session + try: + torrent_name = magnetdl.status().name + except RuntimeError: + torrent_name = "Unknown" + + confirm = QMessageBox.question( + self.main_window, "Cancel Download", + f"Are you sure you want to cancel the download of '{torrent_name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if confirm == QMessageBox.StandardButton.Yes: + with state.downloads_lock: + state.active_downloads.pop(magnet_link, None) + remove_download_log(magnet_link) + + if state.dl_session: + try: + state.dl_session.remove_torrent(magnetdl) + except Exception: + pass + + consoleLog(f"Cancelled download: {torrent_name}", True) + + + def deleteFileAction(self): + if not hasattr(self, '_context_menu_row'): + return + row = self._context_menu_row + with state.downloads_lock: + if row < 0 or row >= len(state.active_downloads): + return + magnet_link = list(state.active_downloads.keys())[row] + magnetdl = state.active_downloads[magnet_link] + try: + status = magnetdl.status() + save_path = status.save_path + torrent_name = status.name + except RuntimeError: + consoleLog("Error: torrent handle already invalid", True) + with state.downloads_lock: + state.active_downloads.pop(magnet_link, None) + remove_download_log(magnet_link) + return + download_path = os.path.join(save_path, torrent_name) + confirm = QMessageBox.question( + self.main_window, "Delete Files", + f"Are you sure you want to delete the downloaded files of '{torrent_name}'? This action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if confirm == QMessageBox.StandardButton.Yes: + with state.downloads_lock: + state.active_downloads.pop(magnet_link, None) + remove_download_log(magnet_link) + if state.dl_session: + try: + state.dl_session.remove_torrent(magnetdl) + time.sleep(0.5) + except Exception: + pass + if download_path and os.path.exists(download_path): + import shutil + last_error = None + for attempt in range(3): + try: + if os.path.isfile(download_path): + os.remove(download_path) + else: + shutil.rmtree(download_path) + consoleLog(f"Deleted files for: {torrent_name}", True) + last_error = None + break + except Exception as e: + last_error = e + if attempt < 2: + time.sleep(0.5) + if last_error: + consoleLog(f"Error deleting files: {last_error}", True) + else: + consoleLog(f"Removed entry (files not found): {torrent_name}", True) diff --git a/src/core/interface/dialogs/downloadlist.py b/src/core/interface/dialogs/downloadlist.py new file mode 100644 index 0000000..e56ef9f --- /dev/null +++ b/src/core/interface/dialogs/downloadlist.py @@ -0,0 +1,88 @@ +from core.interface.dialogs.pauseresumedelegate import PauseResumeDelegate +from core.interface.dialogs.hoverrowdelegate import HoverRowDelegate +from core.interface.dialogs.downloadmodel import DownloadModel +from core.interface.dialogs.theme import _table_stylesheet +from PySide6.QtWidgets import QTableView, QHeaderView +from PySide6 import QtWidgets +from PySide6.QtCore import Qt + +def _create_download_list(self) -> QTableView: + self.download_model = DownloadModel() + self.downloadList.setModel(self.download_model) + self.downloadList.horizontalHeader().setStretchLastSection(False) + self.downloadList.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self.downloadList.horizontalHeader().resizeSection(0, 70) + self.downloadList.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.downloadList.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + self.downloadList.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + self.downloadList.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) + self.downloadList.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) + self.downloadList.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) + self.downloadList.horizontalHeader().setMinimumSectionSize(60) + self.downloadList.verticalHeader().setDefaultSectionSize(40) + self.downloadList.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self.downloadList.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + self.downloadList.verticalHeader().setVisible(False) + self.downloadList.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.downloadList.horizontalHeader().setHighlightSections(False) + self.downloadList.setMouseTracking(True) + self.downloadList.setShowGrid(False) + self.downloadList.setStyleSheet(_table_stylesheet("QTableView")) + self.downloadList.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.downloadList.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + download_model = self.download_model + + def on_pause_resume_clicked(row): + download_model.toggle_pause_resume(row) + + self.downloadList.viewport().setMouseTracking(True) + hover_delegate = HoverRowDelegate(self.downloadList) + self.downloadList.setItemDelegate(hover_delegate) + + delegate = PauseResumeDelegate(self.downloadList) + self.downloadList.setItemDelegateForColumn(0, delegate) + delegate.clicked.connect(on_pause_resume_clicked) + + return self.downloadList + +def download_list_update(self): + self._update_speed_label() + if not self.download_model: + return + + row_count = self.download_model.rowCount() + col_count = self.download_model.columnCount() + + if row_count > 0 and col_count > 0: + top_left = self.download_model.index(0, 0) + bottom_right = self.download_model.index(row_count - 1, col_count - 1) + if top_left.isValid() and bottom_right.isValid(): + self.download_model.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole]) + + if row_count != self._last_dl_row_count: + old_count = self._last_dl_row_count + self._last_dl_row_count = row_count + if row_count > 0: + self.download_model.layoutAboutToBeChanged.emit() + self.download_model.layoutChanged.emit() + for row in range(row_count): + idx = self.download_model.index(row, 0) + if idx.isValid(): + self.downloadList.closePersistentEditor(idx) + self.downloadList.openPersistentEditor(idx) + elif old_count > 0: + self.download_model.layoutAboutToBeChanged.emit() + self.download_model.layoutChanged.emit() + else: + if row_count > 0: + delegate = self.downloadList.itemDelegateForColumn(0) + for row in range(row_count): + idx = self.download_model.index(row, 0) + if idx.isValid(): + editor = self.downloadList.indexWidget(idx) + if editor and delegate: + try: + delegate.setEditorData(editor, idx) + except RuntimeError: + pass diff --git a/src/core/interface/dialogs/downloadmodel.py b/src/core/interface/dialogs/downloadmodel.py new file mode 100644 index 0000000..a4a919f --- /dev/null +++ b/src/core/interface/dialogs/downloadmodel.py @@ -0,0 +1,136 @@ +from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt +from core.utils.logging.logs import consoleLog +from core.utils.data.state import state +import libtorrent as lt +import subprocess +import platform +import os + +class DownloadModel(QAbstractTableModel): + def __init__(self): + super().__init__() + self.headers = ["Action", "Name", "Status", "Progress", "Speed", "Size", "Total Size"] + + def rowCount(self, parent=QModelIndex()): + if parent.isValid(): + return 0 + with state.downloads_lock: + return len(state.active_downloads) + + def columnCount(self, parent=QModelIndex()): + return len(self.headers) + + def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): + if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: + return self.headers[section] + return None + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if not index.isValid(): + return None + + with state.downloads_lock: + if index.row() >= len(state.active_downloads) or index.row() < 0: + return None + + try: + magnet_link = list(state.active_downloads.keys())[index.row()] + magnetdl = state.active_downloads[magnet_link] + status = magnetdl.status() + except (IndexError, KeyError, RuntimeError): + return None + + if role == Qt.ItemDataRole.DisplayRole: + col = index.column() + if col == 0: + pass + elif col == 1: + return status.name if status.has_metadata else "Fetching metadata..." + elif col == 2: + if status.paused: + is_auto = getattr(status, 'auto_managed', True) + return "Queued" if is_auto else "Paused" + elif status.state == lt.torrent_status.downloading: + return "Downloading" + elif status.state == lt.torrent_status.seeding: + return "Seeding" + else: + return "Queued" + elif col == 3: + return f"{status.progress * 100:.1f}%" + elif col == 4: + down_kb = status.download_rate / 1024 + up_kb = status.upload_rate / 1024 + down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s" + up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s" + return f"↓ {down_text} ↑ {up_text}" + elif col == 5: + downloaded_mb = status.total_wanted_done / (1024 * 1024) + if downloaded_mb > 1024: + return f"{downloaded_mb / 1024:.2f} GB" + else: + return f"{downloaded_mb:.1f} MB" + elif col == 6: + total_mb = status.total_wanted / (1024 * 1024) + if total_mb > 1024: + return f"{total_mb / 1024:.2f} GB" + else: + return f"{total_mb:.1f} MB" + elif col == 7: + if status.download_rate > 0: + bytes_left = status.total_wanted - status.total_wanted_done + eta_seconds = bytes_left / status.download_rate + if eta_seconds < 60: + return f"{int(eta_seconds)}s" + elif eta_seconds < 3600: + minutes = int(eta_seconds / 60) + seconds = int(eta_seconds % 60) + return f"{minutes}m {seconds}s" + else: + hours = int(eta_seconds / 3600) + minutes = int((eta_seconds % 3600) / 60) + return f"{hours}h {minutes}m" + else: + return "∞" if status.paused else "Stalled" + if role == Qt.ItemDataRole.UserRole and index.column() == 0: + return status.paused + return None + + def toggle_pause_resume(self, row): + with state.downloads_lock: + if row >= len(state.active_downloads) or row < 0: + return + try: + magnet_link = list(state.active_downloads.keys())[row] + magnetdl = state.active_downloads[magnet_link] + status = magnetdl.status() + except (IndexError, KeyError, RuntimeError): + return + + if status.state == lt.torrent_status.seeding: + try: + save_path = magnetdl.save_path() + if save_path and os.path.exists(save_path): + if platform.system() == "Windows": + os.startfile(os.path.normpath(save_path)) + elif platform.system() == "Linux": + subprocess.Popen(["xdg-open", save_path]) + elif platform.system() == "Darwin": + subprocess.Popen(["open", save_path]) + except Exception: + pass + return + + is_paused = status.paused + if is_paused: + if hasattr(magnetdl, 'set_flags'): + magnetdl.set_flags(lt.torrent_flags.auto_managed) + magnetdl.resume() + consoleLog(f"Resumed download: {status.name}", True) + else: + if hasattr(magnetdl, 'unset_flags'): + magnetdl.unset_flags(lt.torrent_flags.auto_managed) + magnetdl.pause() + consoleLog(f"Paused download: {status.name}", True) + idx = self.index(row, 0) + self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole]) diff --git a/src/core/interface/dialogs/elideditemdelegate.py b/src/core/interface/dialogs/elideditemdelegate.py new file mode 100644 index 0000000..5ef8671 --- /dev/null +++ b/src/core/interface/dialogs/elideditemdelegate.py @@ -0,0 +1,22 @@ +from core.interface.dialogs.theme import _theme_colors +from PySide6.QtWidgets import QStyledItemDelegate +from PySide6.QtCore import Qt + +class ElidedItemDelegate(QStyledItemDelegate): + def __init__(self, get_hovered_row, parent=None): + super().__init__(parent) + self._get_hovered_row = get_hovered_row + + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + if option.text: + metrics = option.fontMetrics + text_rect = option.rect.adjusted(14, 0, -14, 0) + option.text = metrics.elidedText(option.text, Qt.TextElideMode.ElideMiddle, text_rect.width()) + + def paint(self, painter, option, index): + if index.row() == self._get_hovered_row(): + painter.save() + painter.fillRect(option.rect, _theme_colors()["hover"]) + painter.restore() + super().paint(painter, option, index) diff --git a/src/core/interface/dialogs/eventfilter.py b/src/core/interface/dialogs/eventfilter.py new file mode 100644 index 0000000..890265c --- /dev/null +++ b/src/core/interface/dialogs/eventfilter.py @@ -0,0 +1,35 @@ +from core.utils.data.state import state +from PySide6.QtCore import QEvent + +def eventFilter(self, obj, event): + try: + if obj == state.trackertable.viewport(): + if event.type() == QEvent.Type.MouseMove: + pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() + idx = state.trackertable.indexAt(pos) + new_row = idx.row() if idx.isValid() else -1 + if new_row != self._tracker_hovered_row or state.trackertable is not self._tracker_hovered_table: + self._tracker_hovered_row = new_row + self._tracker_hovered_table = state.trackertable + state.trackertable.viewport().update() + elif event.type() == QEvent.Type.Leave: + self._tracker_hovered_row = -1 + self._tracker_hovered_table = None + state.trackertable.viewport().update() + if obj == self.downloadList.viewport(): + if event.type() == QEvent.Type.MouseMove: + pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() + idx = self.downloadList.indexAt(pos) + new_row = idx.row() if idx.isValid() else -1 + if new_row != self._hovered_row: + old_row = self._hovered_row + self._hovered_row = new_row + self._invalidate_hover_row(old_row) + self._invalidate_hover_row(new_row) + elif event.type() == QEvent.Type.Leave: + old_row = self._hovered_row + self._hovered_row = -1 + self._invalidate_hover_row(old_row) + except (RuntimeError, AttributeError): + pass + return super(type(self), self).eventFilter(obj, event) diff --git a/src/core/interface/dialogs/hoverrowdelegate.py b/src/core/interface/dialogs/hoverrowdelegate.py new file mode 100644 index 0000000..3293099 --- /dev/null +++ b/src/core/interface/dialogs/hoverrowdelegate.py @@ -0,0 +1,19 @@ +from core.interface.dialogs.theme import _theme_colors +from PySide6.QtWidgets import QStyledItemDelegate +from PySide6 import QtWidgets + +class HoverRowDelegate(QStyledItemDelegate): + + def paint(self, painter, option, index): + from core.interface.gui import MainWindow + + opt = QtWidgets.QStyleOptionViewItem(option) + option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus + option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected + + hovered_row = MainWindow._instance._hovered_row if MainWindow._instance else -1 + if index.row() == hovered_row: + painter.save() + painter.fillRect(option.rect, _theme_colors()["hover"]) + painter.restore() + super().paint(painter, opt, index) diff --git a/src/core/interface/dialogs/image.py b/src/core/interface/dialogs/image.py new file mode 100644 index 0000000..0abb90c --- /dev/null +++ b/src/core/interface/dialogs/image.py @@ -0,0 +1,29 @@ +from PySide6.QtGui import QImage, QPixmap +from core.utils.data.state import state +from PySide6.QtWidgets import QLabel +from PySide6.QtCore import Qt +import os + +class Image(): + def __init__(self, parent): + self.overlay_label = QLabel(parent) + self.overlay_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + if state.image_path is not None and os.path.exists(state.image_path): + self.image = QImage(state.image_path) + self.image = self.image.scaledToWidth(300, Qt.TransformationMode.SmoothTransformation) + + self.pixmap = QPixmap.fromImage(self.image) + self.overlay_label.setPixmap(self.pixmap) + self.overlay_label.adjustSize() + self.overlay_label.raise_() + + x = parent.width() - self.overlay_label.width() + y = parent.height() - self.overlay_label.height() + self.overlay_label.move(x, y) + + def update_image_overlay(self, new_image_path): + self.image = QImage(new_image_path) + self.pixmap = QPixmap.fromImage(self.image) + self.overlay_label.setPixmap(self.pixmap) + self.overlay_label.adjustSize() diff --git a/src/core/interface/dialogs/pauseresumedelegate.py b/src/core/interface/dialogs/pauseresumedelegate.py new file mode 100644 index 0000000..7570a5b --- /dev/null +++ b/src/core/interface/dialogs/pauseresumedelegate.py @@ -0,0 +1,86 @@ +from core.interface.dialogs.theme import _theme_colors, SVG_PLAY, SVG_PAUSE, SVG_FOLDER +from PySide6.QtWidgets import QStyledItemDelegate, QWidget, QHBoxLayout +from core.interface.utils.svghelper import svg_icon +from PySide6.QtCore import Qt, QSize, Signal +from core.utils.data.state import state +from PySide6 import QtWidgets +import libtorrent as lt + +class PauseResumeDelegate(QStyledItemDelegate): + clicked = Signal(int) + + def paint(self, painter, option, index): + from core.interface.gui import MainWindow + + opt = QtWidgets.QStyleOptionViewItem(option) + option.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus + option.state &= ~QtWidgets.QStyle.StateFlag.State_Selected + + hovered_row = MainWindow._instance._hovered_row if MainWindow._instance else -1 + if index.row() == hovered_row: + painter.save() + painter.fillRect(option.rect, _theme_colors()["hover"]) + painter.restore() + super().paint(painter, opt, index) + + def setEditorData(self, editor, index): + button = editor.findChild(QtWidgets.QPushButton) + if not button: + return + try: + with state.downloads_lock: + keys = list(state.active_downloads.keys()) + if index.row() >= len(keys): + return + magnet_link = keys[index.row()] + magnetdl = state.active_downloads[magnet_link] + status = magnetdl.status() + except (RuntimeError, IndexError, KeyError): + return + + if status.state == lt.torrent_status.seeding: + button.setIcon(svg_icon(SVG_FOLDER, 18)) + button.setText("") + else: + is_user_paused = status.paused and not status.auto_managed + button.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18)) + button.setText("") + + + def createEditor(self, parent, option, index): + try: + with state.downloads_lock: + keys = list(state.active_downloads.keys()) + if index.row() >= len(keys): + return QWidget(parent) + magnet_link = keys[index.row()] + magnetdl = state.active_downloads[magnet_link] + status = magnetdl.status() + except (RuntimeError, IndexError, KeyError): + return QWidget(parent) + + widget = QWidget(parent) + widget.setStyleSheet("border: none; background: transparent;") + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + if status.state == lt.torrent_status.seeding: + btnPause = QtWidgets.QPushButton() + btnPause.setIcon(svg_icon(SVG_FOLDER, 18)) + else: + btnPause = QtWidgets.QPushButton() + is_user_paused = status.paused and not status.auto_managed + btnPause.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18)) + btnPause.setIconSize(QSize(18, 18)) + btnPause.setFixedSize(30, 30) + btnPause.setCursor(Qt.CursorShape.PointingHandCursor) + btnPause.setStyleSheet("QPushButton { border: none; background: transparent; padding: 0px; }") + btnPause.clicked.connect(lambda: self.clicked.emit(index.row())) + layout.addStretch() + layout.addWidget(btnPause) + layout.addStretch() + widget.setLayout(layout) + return widget + + + def editorEvent(self, event, model, option, index): + return False diff --git a/src/core/interface/dialogs/settings.py b/src/core/interface/dialogs/settings.py index 04b4c9f..b45618f 100644 --- a/src/core/interface/dialogs/settings.py +++ b/src/core/interface/dialogs/settings.py @@ -34,7 +34,9 @@ def settings_dialog(self): dialog.setWindowTitle("Settings") dialog.setFixedSize(700, 450) + main_layout = QVBoxLayout() dialog.setLayout(QVBoxLayout()) + dialog.setLayout(main_layout) if state.window_transparency and platform.system() != "Windows" and dialog: dialog.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) @@ -93,7 +95,7 @@ def settings_dialog(self): api_url = QLineEdit() api_url_layout.addWidget(QLabel("API Server URL:")) api_url_layout.addWidget(api_url) - api_url_container.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + api_url_container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) api_url_container.setLayout(api_url_layout) api_url.setText(state.api_url) @@ -107,7 +109,7 @@ def settings_dialog(self): download_path = QLineEdit() download_path_layout.addWidget(QLabel("Download Path:")) download_path_layout.addWidget(download_path) - download_path_container.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + download_path_container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) download_path_container.setLayout(download_path_layout) download_path.setText(state.download_path) @@ -142,7 +144,7 @@ def settings_dialog(self): image_path = QLineEdit() image_path_layout.addWidget(QLabel("Image Path (requires restart, experimental):")) image_path_layout.addWidget(image_path) - image_path_container.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + image_path_container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) image_path_container.setLayout(image_path_layout) image_path.setText(state.image_path) @@ -285,7 +287,7 @@ def settings_dialog(self): self.tab2 = paths_tab("Paths", download_path_container, image_path_container, self.tabs) self.tab3 = network_tab("Network", interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container, self.tabs) - dialog.layout().addWidget(self.tabs) - dialog.layout().addLayout(layout) + main_layout.addWidget(self.tabs) + main_layout.addLayout(layout) dialog.exec() diff --git a/src/core/interface/dialogs/theme.py b/src/core/interface/dialogs/theme.py new file mode 100644 index 0000000..18011f8 --- /dev/null +++ b/src/core/interface/dialogs/theme.py @@ -0,0 +1,79 @@ +from PySide6.QtGui import QColor +import darkdetect + +def _is_dark_mode(): + return darkdetect.isDark() + + +def _theme_colors(): + dark = _is_dark_mode() + if dark: + return { + "text": "rgba(255, 255, 255, 0.9)", + "border": "rgba(255, 255, 255, 0.06)", + "selected": "rgba(255, 255, 255, 0.04)", + "header_text": "rgba(255, 255, 255, 0.5)", + "header_border": "rgba(255, 255, 255, 0.1)", + "hover": QColor(255, 255, 255, 15), + } + else: + return { + "text": "rgba(0, 0, 0, 0.87)", + "border": "rgba(0, 0, 0, 0.08)", + "selected": "rgba(0, 0, 0, 0.06)", + "header_text": "rgba(0, 0, 0, 0.6)", + "header_border": "rgba(0, 0, 0, 0.12)", + "hover": QColor(0, 0, 0, 15), + } + + +def _table_stylesheet(view_type="QTableWidget"): + c = _theme_colors() + dark = _is_dark_mode() + color_rule = "" if dark else f"color: {c['text']};" + return f""" + {view_type} {{ + border: none; + outline: 0; + font-size: 13px; + {color_rule} + }} + {view_type}::item {{ + border-bottom: 1px solid {c["border"]}; + padding: 6px 14px; + outline: none; + {color_rule} + }} + {view_type}::item:selected {{ + background: {c["selected"]}; + outline: none; + border: none; + border-bottom: 1px solid {c["border"]}; + }} + {view_type}::item:focus {{ + outline: none; + border: none; + border-bottom: 1px solid {c["border"]}; + }} + QHeaderView {{ + background: transparent; + }} + QHeaderView::section {{ + background: transparent; + color: {c["header_text"]}; + font-weight: normal; + border: none; + border-bottom: 1px solid {c["header_border"]}; + padding: 6px 14px; + }} + QHeaderView::section:checked {{ + background: transparent; + color: {c["header_text"]}; + font-weight: normal; + }} + """ + + +SVG_PLAY = '' +SVG_PAUSE = '' +SVG_FOLDER = '' diff --git a/src/core/interface/dialogs/trackerhoverdelegate.py b/src/core/interface/dialogs/trackerhoverdelegate.py new file mode 100644 index 0000000..c83af6d --- /dev/null +++ b/src/core/interface/dialogs/trackerhoverdelegate.py @@ -0,0 +1,14 @@ +from core.interface.dialogs.theme import _theme_colors +from PySide6.QtWidgets import QStyledItemDelegate + +class TrackerHoverDelegate(QStyledItemDelegate): + def __init__(self, get_hovered_row, parent=None): + super().__init__(parent) + self._get_hovered_row = get_hovered_row + + def paint(self, painter, option, index): + if index.row() == self._get_hovered_row(): + painter.save() + painter.fillRect(option.rect, _theme_colors()["hover"]) + painter.restore() + super().paint(painter, option, index) diff --git a/src/core/interface/dialogs/trackertable.py b/src/core/interface/dialogs/trackertable.py new file mode 100644 index 0000000..a382e75 --- /dev/null +++ b/src/core/interface/dialogs/trackertable.py @@ -0,0 +1,26 @@ +from core.interface.dialogs.theme import _table_stylesheet +from PySide6.QtWidgets import QTableWidget +from PySide6 import QtWidgets +from PySide6.QtCore import Qt + +def _create_tracker_table(self): + table = QTableWidget() + table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + table.verticalHeader().setVisible(False) + table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + table.setShowGrid(False) + table.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + table.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + table.horizontalHeader().setHighlightSections(False) + table.horizontalHeader().setStretchLastSection(False) + table.setStyleSheet(_table_stylesheet("QTableWidget")) + table.setMouseTracking(True) + table.viewport().setMouseTracking(True) + table.viewport().installEventFilter(self) + + table.setItemDelegateForColumn(0, self._tracker_elided_delegate) + + self._apply_default_headers(table) + return table diff --git a/src/core/interface/dialogs/update.py b/src/core/interface/dialogs/update.py new file mode 100644 index 0000000..cd0ed37 --- /dev/null +++ b/src/core/interface/dialogs/update.py @@ -0,0 +1,31 @@ +from core.utils.network.update_checker import get_updates +from core.utils.network.updater import download_update +from PySide6.QtWidgets import QDialog, QMessageBox +from core.utils.data.state import state +import platform +import json +import os + +def check_version(): + build_info_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "build_info.json") + if os.path.exists(build_info_path): + with open(build_info_path, "r") as f: + build_info = json.load(f) + state.version = build_info.get("version") + + +class UpdateDialog(QDialog): + def __init__(self, parent=None): + if state.ignore_updates is False and platform.system() == "Windows": + + assets, latest = get_updates() + if assets != None: + msg = QMessageBox() + msg.setIcon(QMessageBox.Icon.Information) + msg.setWindowTitle("Update Available") + msg.setText(f"A new version is available\n({latest})") + msg.setInformativeText("Press Ok to download.") + msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore) + response = msg.exec_() + if response == QMessageBox.StandardButton.Ok: + download_update(assets) diff --git a/src/core/interface/gui.py b/src/core/interface/gui.py index 03c7509..054ace1 100644 --- a/src/core/interface/gui.py +++ b/src/core/interface/gui.py @@ -1,26 +1,32 @@ -from core.utils.logging.logs import consoleLog, remove_download_log, flush_log_buffer +from core.utils.logging.logs import consoleLog, flush_log_buffer from core.interface.assets.base64_icons import settings_white_base64 from core.interface.assets.base64_icons import settings_black_base64 from core.interface.utils.searchhelper import return_pressed from core.interface.dialogs.settings import settings_dialog from core.interface.assets.base64_icons import logo_base64 from core.utils.network.download import download_selected -from core.utils.network.update_checker import get_updates -from core.utils.network.updater import download_update from core.interface.utils.tabhelper import create_tab -from core.interface.utils.svghelper import svg_icon from core.utils.general.shutdown import closehelper from core.utils.general.wrappers import run_thread from core.utils.data.state import state +from core.interface.dialogs.trackerhoverdelegate import TrackerHoverDelegate +from core.interface.dialogs.elideditemdelegate import ElidedItemDelegate +from core.interface.dialogs.trackertable import _create_tracker_table +from core.interface.dialogs.downloadlist import _create_download_list +from core.interface.dialogs.downloadlist import download_list_update +from core.interface.dialogs.downloadmodel import DownloadModel +from core.interface.dialogs.eventfilter import eventFilter +from core.interface.dialogs.image import Image +import core.interface.dialogs.hoverrowdelegate +import core.interface.dialogs.contextmenu +import core.interface.dialogs.update + from PySide6 import QtWidgets from PySide6.QtCore import ( Qt, QTimer, - QModelIndex, - QAbstractTableModel, Signal, - QEvent, QSize ) @@ -29,156 +35,33 @@ from PySide6.QtWidgets import ( QTableView, QWidget, QVBoxLayout, - QListWidget, QLabel, QHBoxLayout, QComboBox, QTabWidget, QHeaderView, - QMessageBox, - QTableWidget, QTableWidgetItem, QTextEdit, - QStyledItemDelegate, QStatusBar ) from PySide6.QtGui import ( QIcon, QCloseEvent, - QImage, QPixmap, - QContextMenuEvent, QGuiApplication, - QColor, ) -import libtorrent as lt -import subprocess import darkdetect import threading import platform import base64 -import json -import time -import sys -import os - - -def _is_dark_mode(): - return darkdetect.isDark() - - -def _theme_colors(): - dark = _is_dark_mode() - if dark: - return { - "text": "rgba(255, 255, 255, 0.9)", - "border": "rgba(255, 255, 255, 0.06)", - "selected": "rgba(255, 255, 255, 0.04)", - "header_text": "rgba(255, 255, 255, 0.5)", - "header_border": "rgba(255, 255, 255, 0.1)", - "hover": QColor(255, 255, 255, 15), - } - else: - return { - "text": "rgba(0, 0, 0, 0.87)", - "border": "rgba(0, 0, 0, 0.08)", - "selected": "rgba(0, 0, 0, 0.06)", - "header_text": "rgba(0, 0, 0, 0.6)", - "header_border": "rgba(0, 0, 0, 0.12)", - "hover": QColor(0, 0, 0, 15), - } - - -def _table_stylesheet(view_type="QTableWidget"): - c = _theme_colors() - dark = _is_dark_mode() - color_rule = "" if dark else f"color: {c['text']};" - return f""" - {view_type} {{ - border: none; - outline: 0; - font-size: 13px; - {color_rule} - }} - {view_type}::item {{ - border-bottom: 1px solid {c["border"]}; - padding: 6px 14px; - outline: none; - {color_rule} - }} - {view_type}::item:selected {{ - background: {c["selected"]}; - outline: none; - border: none; - border-bottom: 1px solid {c["border"]}; - }} - {view_type}::item:focus {{ - outline: none; - border: none; - border-bottom: 1px solid {c["border"]}; - }} - QHeaderView {{ - background: transparent; - }} - QHeaderView::section {{ - background: transparent; - color: {c["header_text"]}; - font-weight: normal; - border: none; - border-bottom: 1px solid {c["header_border"]}; - padding: 6px 14px; - }} - QHeaderView::section:checked {{ - background: transparent; - color: {c["header_text"]}; - font-weight: normal; - }} - """ - - -SVG_PLAY = '' -SVG_PAUSE = '' -SVG_FOLDER = '' def windowCloseHelper(): QGuiApplication.quit() -class ElidedItemDelegate(QStyledItemDelegate): - def __init__(self, get_hovered_row, parent=None): - super().__init__(parent) - self._get_hovered_row = get_hovered_row - - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - if option.text: - metrics = option.fontMetrics - text_rect = option.rect.adjusted(14, 0, -14, 0) - option.text = metrics.elidedText(option.text, Qt.TextElideMode.ElideMiddle, text_rect.width()) - - def paint(self, painter, option, index): - if index.row() == self._get_hovered_row(): - painter.save() - painter.fillRect(option.rect, _theme_colors()["hover"]) - painter.restore() - super().paint(painter, option, index) - - -class TrackerHoverDelegate(QStyledItemDelegate): - def __init__(self, get_hovered_row, parent=None): - super().__init__(parent) - self._get_hovered_row = get_hovered_row - - def paint(self, painter, option, index): - if index.row() == self._get_hovered_row(): - painter.save() - painter.fillRect(option.rect, _theme_colors()["hover"]) - painter.restore() - super().paint(painter, option, index) - - class MainWindow(QtWidgets.QMainWindow, QWidget): + eventFilter = eventFilter log_signal = Signal(str) search_results_signal = Signal(list) _instance = None @@ -194,6 +77,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): pixmap.loadFromData(image_data) self.setWindowIcon(QIcon(pixmap)) + # set appid on windows if platform.system() == "Windows": try: import ctypes @@ -202,24 +86,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): except Exception as e: consoleLog(f"Could not set app ID: {e}") - build_info_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "build_info.json") - if os.path.exists(build_info_path): - with open(build_info_path, "r") as f: - build_info = json.load(f) - state.version = build_info.get("version") - - if state.ignore_updates is False and platform.system() == "Windows": - assets, latest = get_updates() - if assets != None: - msg = QMessageBox() - msg.setIcon(QMessageBox.Icon.Information) - msg.setWindowTitle("Update Available") - msg.setText(f"A new version is available\n({latest})") - msg.setInformativeText("Press Ok to download.") - msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore) - response = msg.exec_() - if response == QMessageBox.StandardButton.Ok: - download_update(assets) + core.interface.dialogs.update self.setWindowTitle("Software Manager") self.setGeometry(100, 100, 800, 600) @@ -235,21 +102,17 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.dlbutton = QtWidgets.QPushButton("Download") self.dlbutton.setCursor(Qt.CursorShape.PointingHandCursor) - self.libraryList = QListWidget() self.emptyResults = QLabel("No Results") self.emptyResults.setAlignment(Qt.AlignmentFlag.AlignCenter) self.emptyResults.hide() - self.download_model = None self.downloadList = QTableView() self.downloadList.setMouseTracking(True) self._hovered_row = -1 self._tracker_hovered_row = -1 self._tracker_hovered_table = None self._last_dl_row_count = 0 - self.downloadList.viewport().installEventFilter(self) - self.emptyLibrary = QLabel("No items in library.") self.emptyDownload = QLabel("No items in downloads.") self.emptyDownload.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -261,264 +124,17 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self._tracker_elided_delegate = ElidedItemDelegate(lambda: self._tracker_hovered_row, self) self._tracker_hover_delegate = TrackerHoverDelegate(lambda: self._tracker_hovered_row, self) - state.trackertable = self._create_tracker_table() + state.trackertable = _create_tracker_table(self) container = QWidget() containerLayout = QVBoxLayout() containerLayout.addWidget(self.searchbar) containerLayout.addWidget(state.trackertable) - class DownloadModel(QAbstractTableModel): - def __init__(self): - super().__init__() - self.headers = ["Action", "Name", "Status", "Progress", "Speed", "Size", "Total Size"] - - def rowCount(self, parent=QModelIndex()): - if parent.isValid(): - return 0 - with state.downloads_lock: - return len(state.active_downloads) - - def columnCount(self, parent=QModelIndex()): - return len(self.headers) - - def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): - if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: - return self.headers[section] - return None - - def data(self, index, role=Qt.ItemDataRole.DisplayRole): - if not index.isValid(): - return None - - with state.downloads_lock: - if index.row() >= len(state.active_downloads) or index.row() < 0: - return None - - try: - magnet_link = list(state.active_downloads.keys())[index.row()] - magnetdl = state.active_downloads[magnet_link] - status = magnetdl.status() - except (IndexError, KeyError, RuntimeError): - return None - - if role == Qt.ItemDataRole.DisplayRole: - col = index.column() - if col == 0: - pass - elif col == 1: - return status.name if status.has_metadata else "Fetching metadata..." - elif col == 2: - if status.paused: - is_auto = getattr(status, 'auto_managed', True) - return "Queued" if is_auto else "Paused" - elif status.state == lt.torrent_status.downloading: - return "Downloading" - elif status.state == lt.torrent_status.seeding: - return "Seeding" - else: - return "Queued" - elif col == 3: - return f"{status.progress * 100:.1f}%" - elif col == 4: - down_kb = status.download_rate / 1024 - up_kb = status.upload_rate / 1024 - down_text = f"{down_kb / 1024:.1f} MB/s" if down_kb > 1024 else f"{down_kb:.1f} kB/s" - up_text = f"{up_kb / 1024:.1f} MB/s" if up_kb > 1024 else f"{up_kb:.1f} kB/s" - return f"↓ {down_text} ↑ {up_text}" - elif col == 5: - downloaded_mb = status.total_wanted_done / (1024 * 1024) - if downloaded_mb > 1024: - return f"{downloaded_mb / 1024:.2f} GB" - else: - return f"{downloaded_mb:.1f} MB" - elif col == 6: - total_mb = status.total_wanted / (1024 * 1024) - if total_mb > 1024: - return f"{total_mb / 1024:.2f} GB" - else: - return f"{total_mb:.1f} MB" - elif col == 7: - if status.download_rate > 0: - bytes_left = status.total_wanted - status.total_wanted_done - eta_seconds = bytes_left / status.download_rate - if eta_seconds < 60: - return f"{int(eta_seconds)}s" - elif eta_seconds < 3600: - minutes = int(eta_seconds / 60) - seconds = int(eta_seconds % 60) - return f"{minutes}m {seconds}s" - else: - hours = int(eta_seconds / 3600) - minutes = int((eta_seconds % 3600) / 60) - return f"{hours}h {minutes}m" - else: - return "∞" if status.paused else "Stalled" - if role == Qt.ItemDataRole.UserRole and index.column() == 0: - return status.paused - return None - - def toggle_pause_resume(self, row): - with state.downloads_lock: - if row >= len(state.active_downloads) or row < 0: - return - try: - magnet_link = list(state.active_downloads.keys())[row] - magnetdl = state.active_downloads[magnet_link] - status = magnetdl.status() - except (IndexError, KeyError, RuntimeError): - return - - if status.state == lt.torrent_status.seeding: - try: - save_path = magnetdl.save_path() - if save_path and os.path.exists(save_path): - if platform.system() == "Windows": - os.startfile(os.path.normpath(save_path)) - elif platform.system() == "Linux": - subprocess.Popen(["xdg-open", save_path]) - elif platform.system() == "Darwin": - subprocess.Popen(["open", save_path]) - except Exception: - pass - return - - is_paused = status.paused - if is_paused: - if hasattr(magnetdl, 'set_flags'): - magnetdl.set_flags(lt.torrent_flags.auto_managed) - magnetdl.resume() - consoleLog(f"Resumed download: {status.name}", True) - else: - if hasattr(magnetdl, 'unset_flags'): - magnetdl.unset_flags(lt.torrent_flags.auto_managed) - magnetdl.pause() - consoleLog(f"Paused download: {status.name}", True) - idx = self.index(row, 0) - self.dataChanged.emit(idx, idx, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole]) - - class HoverRowDelegate(QStyledItemDelegate): - def paint(self, painter, option, index): - opt = QtWidgets.QStyleOptionViewItem(option) - opt.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus - opt.state &= ~QtWidgets.QStyle.StateFlag.State_Selected - if index.row() == MainWindow._instance._hovered_row: - painter.save() - painter.fillRect(opt.rect, _theme_colors()["hover"]) - painter.restore() - super().paint(painter, opt, index) - - class PauseResumeDelegate(QStyledItemDelegate): - clicked = Signal(int) - - def paint(self, painter, option, index): - opt = QtWidgets.QStyleOptionViewItem(option) - opt.state &= ~QtWidgets.QStyle.StateFlag.State_HasFocus - opt.state &= ~QtWidgets.QStyle.StateFlag.State_Selected - if index.row() == MainWindow._instance._hovered_row: - painter.save() - painter.fillRect(opt.rect, _theme_colors()["hover"]) - painter.restore() - super().paint(painter, opt, index) - - def setEditorData(self, editor, index): - button = editor.findChild(QtWidgets.QPushButton) - if not button: - return - try: - with state.downloads_lock: - keys = list(state.active_downloads.keys()) - if index.row() >= len(keys): - return - magnet_link = keys[index.row()] - magnetdl = state.active_downloads[magnet_link] - status = magnetdl.status() - except (RuntimeError, IndexError, KeyError): - return - - if status.state == lt.torrent_status.seeding: - button.setIcon(svg_icon(SVG_FOLDER, 18)) - button.setText("") - else: - is_user_paused = status.paused and not status.auto_managed - button.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18)) - button.setText("") - - - def createEditor(self, parent, option, index): - try: - with state.downloads_lock: - keys = list(state.active_downloads.keys()) - if index.row() >= len(keys): - return QWidget(parent) - magnet_link = keys[index.row()] - magnetdl = state.active_downloads[magnet_link] - status = magnetdl.status() - except (RuntimeError, IndexError, KeyError): - return QWidget(parent) - - widget = QWidget(parent) - widget.setStyleSheet("border: none; background: transparent;") - layout = QHBoxLayout(widget) - layout.setContentsMargins(0, 0, 0, 0) - if status.state == lt.torrent_status.seeding: - btnPause = QtWidgets.QPushButton() - btnPause.setIcon(svg_icon(SVG_FOLDER, 18)) - else: - btnPause = QtWidgets.QPushButton() - is_user_paused = status.paused and not status.auto_managed - btnPause.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18)) - btnPause.setIconSize(QSize(18, 18)) - btnPause.setFixedSize(30, 30) - btnPause.setCursor(Qt.CursorShape.PointingHandCursor) - btnPause.setStyleSheet("QPushButton { border: none; background: transparent; padding: 0px; }") - btnPause.clicked.connect(lambda: self.clicked.emit(index.row())) - layout.addStretch() - layout.addWidget(btnPause) - layout.addStretch() - widget.setLayout(layout) - return widget - - - def editorEvent(self, event, model, option, index): - return False - + core.interface.dialogs.hoverrowdelegate self.download_model = DownloadModel() - self.downloadList.setModel(self.download_model) - self.downloadList.horizontalHeader().setStretchLastSection(False) - self.downloadList.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) - self.downloadList.horizontalHeader().resizeSection(0, 70) - self.downloadList.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - self.downloadList.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) - self.downloadList.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) - self.downloadList.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) - self.downloadList.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) - self.downloadList.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) - self.downloadList.horizontalHeader().setMinimumSectionSize(60) - self.downloadList.verticalHeader().setDefaultSectionSize(40) - self.downloadList.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) - self.downloadList.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) - self.downloadList.verticalHeader().setVisible(False) - self.downloadList.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) - self.downloadList.horizontalHeader().setHighlightSections(False) - self.downloadList.setMouseTracking(True) - self.downloadList.setShowGrid(False) - self.downloadList.setStyleSheet(_table_stylesheet("QTableView")) - self.downloadList.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - self.downloadList.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - - download_model = self.download_model - - def on_pause_resume_clicked(row): - download_model.toggle_pause_resume(row) - - self.downloadList.viewport().setMouseTracking(True) - hover_delegate = HoverRowDelegate(self.downloadList) - self.downloadList.setItemDelegate(hover_delegate) - - delegate = PauseResumeDelegate(self.downloadList) - self.downloadList.setItemDelegateForColumn(0, delegate) - delegate.clicked.connect(on_pause_resume_clicked) + self.downloadList = _create_download_list(self) + self.downloadList.viewport().installEventFilter(self) self.dlbutton.clicked.connect(lambda: run_thread(threading.Thread(target=download_selected, args=(state.trackertable.selectedItems(),)))) @@ -542,19 +158,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.horizontal_layout.addWidget(state.trackertable) self.tab1 = create_tab("Search", self.searchbar, state.trackertable, self.tabs, self.dlbutton, self.horizontal_layout) - self.tab3 = create_tab("Downloads", self.emptyDownload, self.downloadList, self.tabs, None, None) - - if state.image_path is not None and os.path.exists(state.image_path): - self.image = QImage(state.image_path) - self.image = self.image.scaledToWidth(300, Qt.TransformationMode.SmoothTransformation) - self.pixmap = QPixmap.fromImage(self.image) - self.overlay_label = QLabel(self) - self.overlay_label.setPixmap(self.pixmap) - self.overlay_label.adjustSize() - self.overlay_label.raise_() - x = self.width() - self.overlay_label.width() - y = self.height() - self.overlay_label.height() - self.overlay_label.move(x, y) + self.tab2 = create_tab("Downloads", self.emptyDownload, self.downloadList, self.tabs, None, None) self.corner_widget = QWidget() self.corner_layout = QHBoxLayout(self.corner_widget) @@ -624,40 +228,15 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.setStatusBar(self.statusbar) self.download_timer = QTimer() - self.download_timer.timeout.connect(self.download_list_update) + self.download_timer.timeout.connect(lambda: download_list_update(self)) self.download_timer.start(500) self.active_timer = QTimer() self.active_timer.timeout.connect(self.show_empty_downloads) self.active_timer.start(500) - self.context_menu = QtWidgets.QMenu(self) - self.context_menu.addAction("Open Containing Folder", self.openFolderAction) - self.context_menu.addAction("Copy Magnet URI", self.copyMagnetURIAction) - self.context_menu.addAction("Remove from list", self.cancelDownloadAction) - self.context_menu.addAction("Delete File", self.deleteFileAction) - - def _create_tracker_table(self): - table = QTableWidget() - table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) - table.verticalHeader().setVisible(False) - table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) - table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) - table.setShowGrid(False) - table.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - table.viewport().setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - table.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) - table.horizontalHeader().setHighlightSections(False) - table.horizontalHeader().setStretchLastSection(False) - table.setStyleSheet(_table_stylesheet("QTableWidget")) - table.setMouseTracking(True) - table.viewport().setMouseTracking(True) - table.viewport().installEventFilter(self) - - table.setItemDelegateForColumn(0, self._tracker_elided_delegate) - - self._apply_default_headers(table) - return table + self._context_menu = core.interface.dialogs.contextmenu.ContextMenu(self) + self.image_overlay = Image(self) def _apply_default_headers(self, table): tracker_name = state.currenttracker if hasattr(state, 'currenttracker') and state.currenttracker else None @@ -758,53 +337,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.searchbar.setEnabled(True) self.searchbar.setFocus() - def update_image_overlay(self, new_image_path): - self.image = QImage(new_image_path) - self.pixmap = QPixmap.fromImage(self.image) - self.overlay_label.setPixmap(self.pixmap) - self.overlay_label.adjustSize() - - def download_list_update(self): - if not self.download_model: - self._update_speed_label() - return - - row_count = self.download_model.rowCount() - col_count = self.download_model.columnCount() - - if row_count > 0 and col_count > 0: - top_left = self.download_model.index(0, 0) - bottom_right = self.download_model.index(row_count - 1, col_count - 1) - if top_left.isValid() and bottom_right.isValid(): - self.download_model.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.UserRole]) - - if row_count != self._last_dl_row_count: - old_count = self._last_dl_row_count - self._last_dl_row_count = row_count - if row_count > 0: - self.download_model.layoutAboutToBeChanged.emit() - self.download_model.layoutChanged.emit() - for row in range(row_count): - idx = self.download_model.index(row, 0) - if idx.isValid(): - self.downloadList.closePersistentEditor(idx) - self.downloadList.openPersistentEditor(idx) - elif old_count > 0: - self.download_model.layoutAboutToBeChanged.emit() - self.download_model.layoutChanged.emit() - else: - if row_count > 0: - delegate = self.downloadList.itemDelegateForColumn(0) - for row in range(row_count): - idx = self.download_model.index(row, 0) - if idx.isValid(): - editor = self.downloadList.indexWidget(idx) - if editor and delegate: - try: - delegate.setEditorData(editor, idx) - except RuntimeError: - pass - self._update_speed_label() @@ -844,43 +376,6 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): from core.utils.general.shutdown import force_exit force_exit() - def eventFilter(self, obj, event): - try: - if hasattr(self, 'speed_label') and obj == self.speed_label: - if event.type() == QEvent.Type.MouseButtonRelease: - settings_dialog(self) - return True - if obj == state.trackertable.viewport(): - if event.type() == QEvent.Type.MouseMove: - pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() - idx = state.trackertable.indexAt(pos) - new_row = idx.row() if idx.isValid() else -1 - if new_row != self._tracker_hovered_row or state.trackertable is not self._tracker_hovered_table: - self._tracker_hovered_row = new_row - self._tracker_hovered_table = state.trackertable - state.trackertable.viewport().update() - elif event.type() == QEvent.Type.Leave: - self._tracker_hovered_row = -1 - self._tracker_hovered_table = None - state.trackertable.viewport().update() - if obj == self.downloadList.viewport(): - if event.type() == QEvent.Type.MouseMove: - pos = event.position().toPoint() if hasattr(event, 'position') else event.pos() - idx = self.downloadList.indexAt(pos) - new_row = idx.row() if idx.isValid() else -1 - if new_row != self._hovered_row: - old_row = self._hovered_row - self._hovered_row = new_row - self._invalidate_hover_row(old_row) - self._invalidate_hover_row(new_row) - elif event.type() == QEvent.Type.Leave: - old_row = self._hovered_row - self._hovered_row = -1 - self._invalidate_hover_row(old_row) - except (RuntimeError, AttributeError): - pass - return super().eventFilter(obj, event) - def _invalidate_hover_row(self, row): if row >= 0 and self.download_model: self.downloadList.viewport().update() @@ -906,132 +401,3 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): else: self.emptyDownload.show() self.downloadList.hide() - - def contextMenuEvent(self, event: QContextMenuEvent): - if self.downloadList.underMouse(): - pos = self.downloadList.viewport().mapFromGlobal(event.globalPos()) - index = self.downloadList.indexAt(pos) - if index.isValid(): - row = index.row() - self._context_menu_row = row - self.context_menu.exec(event.globalPos()) - else: - super().contextMenuEvent(event) - - def openFolderAction(self): - if not hasattr(self, '_context_menu_row'): - return - row = self._context_menu_row - if row < 0 or row >= len(state.active_downloads): - return - magnet_link = list(state.active_downloads.keys())[row] - magnetdl = state.active_downloads[magnet_link] - download_path = magnetdl.save_path() - if download_path and os.path.exists(download_path): - if platform.system() == "Windows": - os.startfile(os.path.normpath(download_path)) - elif platform.system() == "Linux": - subprocess.Popen(["xdg-open", download_path]) - elif platform.system() == "Darwin": - subprocess.Popen(["open", download_path]) - - def copyMagnetURIAction(self): - if not hasattr(self, '_context_menu_row'): - return - row = self._context_menu_row - if row < 0 or row >= len(state.active_downloads): - return - magnet_link = list(state.active_downloads.keys())[row] - clipboard = QtWidgets.QApplication.clipboard() - clipboard.setText(magnet_link) - - def cancelDownloadAction(self): - if not hasattr(self, '_context_menu_row'): - return - row = self._context_menu_row - with state.downloads_lock: - if row < 0 or row >= len(state.active_downloads): - return - magnet_link = list(state.active_downloads.keys())[row] - magnetdl = state.active_downloads[magnet_link] - - # Cache the name BEFORE removing from session - try: - torrent_name = magnetdl.status().name - except RuntimeError: - torrent_name = "Unknown" - - confirm = QMessageBox.question( - self, "Cancel Download", - f"Are you sure you want to cancel the download of '{torrent_name}'?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - if confirm == QMessageBox.StandardButton.Yes: - with state.downloads_lock: - state.active_downloads.pop(magnet_link, None) - remove_download_log(magnet_link) - - if state.dl_session: - try: - state.dl_session.remove_torrent(magnetdl) - except Exception: - pass - - consoleLog(f"Cancelled download: {torrent_name}", True) - - - def deleteFileAction(self): - if not hasattr(self, '_context_menu_row'): - return - row = self._context_menu_row - with state.downloads_lock: - if row < 0 or row >= len(state.active_downloads): - return - magnet_link = list(state.active_downloads.keys())[row] - magnetdl = state.active_downloads[magnet_link] - try: - status = magnetdl.status() - save_path = status.save_path - torrent_name = status.name - except RuntimeError: - consoleLog("Error: torrent handle already invalid", True) - with state.downloads_lock: - state.active_downloads.pop(magnet_link, None) - remove_download_log(magnet_link) - return - download_path = os.path.join(save_path, torrent_name) - confirm = QMessageBox.question( - self, "Delete Files", - f"Are you sure you want to delete the downloaded files of '{torrent_name}'? This action cannot be undone.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - if confirm == QMessageBox.StandardButton.Yes: - with state.downloads_lock: - state.active_downloads.pop(magnet_link, None) - remove_download_log(magnet_link) - if state.dl_session: - try: - state.dl_session.remove_torrent(magnetdl) - time.sleep(0.5) - except Exception: - pass - if download_path and os.path.exists(download_path): - import shutil - last_error = None - for attempt in range(3): - try: - if os.path.isfile(download_path): - os.remove(download_path) - else: - shutil.rmtree(download_path) - consoleLog(f"Deleted files for: {torrent_name}", True) - last_error = None - break - except Exception as e: - last_error = e - if attempt < 2: - time.sleep(0.5) - if last_error: - consoleLog(f"Error deleting files: {last_error}", True) - else: - consoleLog(f"Removed entry (files not found): {torrent_name}", True) \ No newline at end of file diff --git a/src/core/utils/network/update_checker.py b/src/core/utils/network/update_checker.py index e1e76c8..b8ecb24 100644 --- a/src/core/utils/network/update_checker.py +++ b/src/core/utils/network/update_checker.py @@ -10,11 +10,11 @@ def get_updates(): response = requests.get(url, timeout=15) except requests.RequestException as e: consoleLog(f"Failed to fetch releases: {e}") - return None + return None, None if response.status_code != 200: consoleLog(f"Failed to fetch releases: {response.status_code}") - return None + return None, None release = response.json() From 79f9155d76990368bb70bb4b39ec4d7dd4616fc3 Mon Sep 17 00:00:00 2001 From: KeksNino Date: Fri, 13 Mar 2026 19:26:38 +0100 Subject: [PATCH 2/2] fix settings dialog being empty --- src/core/interface/dialogs/settings.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/core/interface/dialogs/settings.py b/src/core/interface/dialogs/settings.py index b45618f..5589b68 100644 --- a/src/core/interface/dialogs/settings.py +++ b/src/core/interface/dialogs/settings.py @@ -34,9 +34,8 @@ def settings_dialog(self): dialog.setWindowTitle("Settings") dialog.setFixedSize(700, 450) - main_layout = QVBoxLayout() - dialog.setLayout(QVBoxLayout()) - dialog.setLayout(main_layout) + dialog_layout = QVBoxLayout() + dialog.setLayout(dialog_layout) if state.window_transparency and platform.system() != "Windows" and dialog: dialog.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) @@ -287,7 +286,7 @@ def settings_dialog(self): self.tab2 = paths_tab("Paths", download_path_container, image_path_container, self.tabs) self.tab3 = network_tab("Network", interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container, self.tabs) - main_layout.addWidget(self.tabs) - main_layout.addLayout(layout) + dialog_layout.addWidget(self.tabs) + dialog_layout.addLayout(layout) dialog.exec()