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()