diff --git a/src/interface/dialogs/image.py b/src/interface/dialogs/image.py index f39c327..f921958 100644 --- a/src/interface/dialogs/image.py +++ b/src/interface/dialogs/image.py @@ -1,29 +1,187 @@ +from PySide6.QtWidgets import QLabel, QGraphicsOpacityEffect, QStackedWidget +from PySide6.QtCore import Qt, QSize, QEvent, QObject from PySide6.QtGui import QImage, QPixmap -from PySide6.QtWidgets import QLabel from utils.data.state import state -from PySide6.QtCore import Qt +import darkdetect import os -class Image(): + +class Image(QObject): def __init__(self, parent): + super().__init__(parent) + self.application = parent self.overlay_label = QLabel(parent) self.overlay_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + + self.opacity_effect = QGraphicsOpacityEffect() + self.overlay_label.setGraphicsEffect(self.opacity_effect) - 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._current_image_path = None + self._wallpaper_active = False + self._original_stylesheets = {} + parent.installEventFilter(self) + state.image_changed.connect(self.update_image_overlay) - self.pixmap = QPixmap.fromImage(self.image) - self.overlay_label.setPixmap(self.pixmap) + if state.image_path and os.path.exists(state.image_path): + self._load_and_display(state.image_path) + + def eventFilter(self, obj, event): + if obj == self.application and event.type() == QEvent.Type.Resize: + if self._current_image_path: + self._load_and_display(self._current_image_path) + return False + + def _set_wallpaper_transparency(self, enabled): + if self._wallpaper_active == enabled: + return + self._wallpaper_active = enabled + parent = self.application + + central = parent.centralWidget() + if central: + central.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled) + + for attr in ['tab_wrapper', 'corner_widget', 'titlebar']: + w = getattr(parent, attr, None) + if w: + w.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled) + + if hasattr(parent, 'tabs'): + tabs = parent.tabs + tabs.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled) + + for i in range(tabs.count()): + page = tabs.widget(i) + if page: + page.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled) + + stack = tabs.findChild(QStackedWidget) + if stack: + stack.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled) + + tab_bar = tabs.tabBar() + if tab_bar: + tab_bar.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled) + + if 'tabs' not in self._original_stylesheets: + self._original_stylesheets['tabs'] = tabs.styleSheet() + if enabled: + tabs.setStyleSheet( + self._original_stylesheets['tabs'] + + "\nQTabWidget::pane { background: transparent; }" + + "\nQTabBar { background: transparent; }" + + "\nQTabBar::tab { background: transparent; }" + ) + else: + tabs.setStyleSheet(self._original_stylesheets['tabs']) + + # Searchbar + if hasattr(parent, 'searchbar'): + if 'searchbar' not in self._original_stylesheets: + self._original_stylesheets['searchbar'] = parent.searchbar.styleSheet() + if enabled: + parent.searchbar.setStyleSheet("QLineEdit { background-color: transparent; }") + else: + parent.searchbar.setStyleSheet(self._original_stylesheets['searchbar']) + + # Download button + if hasattr(parent, 'dlbutton'): + if 'dlbutton' not in self._original_stylesheets: + self._original_stylesheets['dlbutton'] = parent.dlbutton.styleSheet() + if enabled: + parent.dlbutton.setStyleSheet("QPushButton { background-color: transparent; }") + else: + parent.dlbutton.setStyleSheet(self._original_stylesheets['dlbutton']) + + # Labels + for attr in ['emptyResults', 'emptyDownload']: + w = getattr(parent, attr, None) + if w: + w.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, enabled) + + if hasattr(parent, 'tracker_list'): + if 'tracker_list' not in self._original_stylesheets: + self._original_stylesheets['tracker_list'] = parent.tracker_list.styleSheet() + if enabled: + popup_bg = '#1e1e1e' if darkdetect.isDark() else '#ffffff' + parent.tracker_list.setStyleSheet( + "QComboBox { background-color: transparent; }" + f" QComboBox QAbstractItemView {{ background-color: {popup_bg}; }}" + ) + else: + parent.tracker_list.setStyleSheet(self._original_stylesheets['tracker_list']) + + def _load_and_display(self, image_path): + if state.image_enabled is not True: + self.overlay_label.hide() + self._set_wallpaper_transparency(False) + return + self._current_image_path = image_path + parent = self.application + image = QImage(image_path) + if image.isNull(): + self.overlay_label.hide() + return + + if getattr(state, "image_as_wallpaper", False): + scaled_image = image.scaled( + parent.size(), + Qt.AspectRatioMode.KeepAspectRatioByExpanding, + Qt.TransformationMode.SmoothTransformation + ) + + crop_x = (scaled_image.width() - parent.width()) // 2 + crop_y = (scaled_image.height() - parent.height()) // 2 + scaled_image = scaled_image.copy(crop_x, crop_y, parent.width(), parent.height()) + + pixmap = QPixmap.fromImage(scaled_image) + self.overlay_label.setPixmap(pixmap) + + self.overlay_label.setGeometry(0, 0, parent.width(), parent.height()) + self.overlay_label.lower() + + self.opacity_effect.setOpacity(state.image_opacity / 100) + self._set_wallpaper_transparency(True) + + else: + self._set_wallpaper_transparency(False) + scaled_image = image.scaledToWidth( + state.image_width, Qt.TransformationMode.SmoothTransformation + ) + + max_width = parent.width() + max_height = parent.height() + if scaled_image.width() > max_width or scaled_image.height() > max_height: + scaled_image = scaled_image.scaled( + QSize(max_width, max_height), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + pixmap = QPixmap.fromImage(scaled_image) + self.overlay_label.setPixmap(pixmap) self.overlay_label.adjustSize() self.overlay_label.raise_() - x = parent.width() - self.overlay_label.width() - y = parent.height() - self.overlay_label.height() + pos = state.image_position + off = int(state.image_offset) + w = self.overlay_label.width() + h = self.overlay_label.height() + if pos == "top-left": + x, y = off, off + elif pos == "top-right": + x, y = parent.width() - w - off, off + elif pos == "bottom-left": + x, y = off, parent.height() - h - off + elif pos == "center": + x, y = (parent.width() - w) // 2, (parent.height() - h) // 2 + else: # bottom-right (default) + x, y = parent.width() - w - off, parent.height() - h - off + + self.opacity_effect.setOpacity(state.image_opacity / 100) + self.overlay_label.move(x, y) + self.overlay_label.show() 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() + self._load_and_display(new_image_path) \ No newline at end of file diff --git a/src/interface/dialogs/notificationpopup.py b/src/interface/dialogs/notificationpopup.py new file mode 100644 index 0000000..9f78ca8 --- /dev/null +++ b/src/interface/dialogs/notificationpopup.py @@ -0,0 +1,17 @@ +from PySide6.QtWidgets import QMessageBox +import qdarktheme + +class NotificationPopup(QMessageBox): + def __init__(self, title, text, infotext=None, parent=None): + super().__init__(parent) + + qdarktheme.setup_theme("auto") + + self.setIcon(QMessageBox.Icon.Information) + self.setWindowTitle(title) + self.setText(text) + + if infotext is not None: + self.setInformativeText(infotext) + + self.setStandardButtons(QMessageBox.StandardButton.Ok) \ No newline at end of file diff --git a/src/interface/dialogs/settings.py b/src/interface/dialogs/settings.py index 0682323..6b6a25e 100644 --- a/src/interface/dialogs/settings.py +++ b/src/interface/dialogs/settings.py @@ -13,8 +13,7 @@ from PySide6.QtWidgets import ( QDialog, QLabel, QHBoxLayout, - QSpinBox, - QCheckBox, + QSpinBox, QSlider, QCheckBox, QFileDialog, QComboBox, ) @@ -25,248 +24,305 @@ SVG_FOLDER = '= 0: - interface_select.setCurrentIndex(index) - else: - interface_select.setCurrentIndex(0) - - interface_select.setFixedWidth(180) - interface_select.setFixedHeight(30) - interface_layout.addWidget(interface_select) - interface_container.setLayout(interface_layout) - - - # Save / Cancel buttons + def create_widget(widget_type, label_text, **kwargs): + container = QWidget() layout = QHBoxLayout() + container.setLayout(layout) + layout.addWidget(QLabel(label_text)) - save_btn = QPushButton("Save") - cancel_btn = QPushButton("Cancel") - save_btn.clicked.connect(lambda: save_settings( - close_settings, + widget = widget_type() + + if widget_type in (QSpinBox,): + layout.addWidget(widget) + widget.setMinimum(kwargs.get("minimum", 0)) + widget.setMaximum(kwargs.get("maximum", 10000000)) + widget.setFixedWidth(kwargs.get("width", 180)) + widget.setFixedHeight(kwargs.get("height", 30)) + elif widget_type in (QCheckBox,): + layout.addStretch() + layout.addWidget(widget) + elif widget_type in (QLineEdit,): + layout.addWidget(widget) + container.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + if "width" in kwargs: + widget.setFixedWidth(kwargs["width"]) + if "height" in kwargs: + widget.setFixedHeight(kwargs["height"]) + elif widget_type in (QComboBox,): + layout.addWidget(widget) + widget.setFixedWidth(kwargs.get("width", 180)) + widget.setFixedHeight(kwargs.get("height", 30)) + + return container, widget + + consoleLog("Settings dialog opened") + dialog = QDialog(self) + dialog.setWindowTitle("Settings") + dialog.setFixedSize(580, 400) + + dialog_layout = QVBoxLayout() + dialog.setLayout(dialog_layout) + + if state.window_transparency and platform.system() != "Windows" and dialog: + dialog.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + # Ignore Updates checkbox + update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ") + if platform.system() == "Windows": + update_checkbox.setChecked(state.ignore_updates) + update_checkbox.toggled.connect(lambda checked: setattr(state, 'ignore_updates', checked)) + + # Autoresume Container Checkbox + autoresume_container, autoresume_checkbox = create_widget(QCheckBox, "Auto-Resume Downloads: ") + autoresume_checkbox.setChecked(state.autoresume) + autoresume_checkbox.toggled.connect(lambda checked: setattr(state, 'autoresume', checked)) + + # Transparent Window Checkbox + transparent_window_container, transparent_window_checkbox = create_widget(QCheckBox, "Window Transparency (requires restart) (Linux/MacOS only): ") + transparent_window_checkbox.setChecked(state.window_transparency) + transparent_window_checkbox.toggled.connect(lambda checked: setattr(state, 'window_transparency', checked)) + + # Accent Color + accent_color_container, accent_color_input = create_widget(QLineEdit, "Accent Color (requires restart): ", width=180, height=30) + accent_color_input.setPlaceholderText("e.g. #fca7d7") + accent_color_input.setText(state.accent_color) + + # API URL Widget + api_url_container, api_url = create_widget(QLineEdit, "API Server URL: ", width=180, height=30) + api_url.setText(state.api_url) + + # Download Path Widget + download_path_container, download_path = create_widget(QLineEdit, "Download Path: ") + download_path.setText(state.download_path) + download_path_layout = download_path_container.layout() + + def browse_download_path(): + dir_path = QFileDialog.getExistingDirectory(dialog, "Select Download Directory", state.download_path) + if dir_path: + download_path.setText(dir_path) + + browse_button = create_widget(QPushButton, "", width=36, height=36)[1] + browse_button.setIconSize(QSize(24, 24)) + browse_button.setIcon(svg_icon(SVG_FOLDER, 24)) + browse_button.setCursor(Qt.CursorShape.PointingHandCursor) + browse_button.setStyleSheet(""" + QPushButton { + border: none; + background: transparent; + padding: 0px; + } + """) + + download_path_layout.addWidget(browse_button) + browse_button.clicked.connect(browse_download_path) + + + # Image Path + image_path_container, image_path = create_widget(QLineEdit, "Image Path: ") + image_path.setText(state.image_path) + image_path_layout = image_path_container.layout() + image_path.textChanged.connect(lambda text: setattr(state, 'image_path', text)) + + def browse_image_path(): + file_path = QFileDialog.getOpenFileName(dialog, "Select Image File", state.image_path, "Image Files (*.png *.jpg)")[0] + if file_path: + image_path.setText(file_path) + + browse_button = create_widget(QPushButton, "", width=36, height=36)[1] + browse_button.setIconSize(QSize(24, 24)) + browse_button.setIcon(svg_icon(SVG_FOLDER, 24)) + browse_button.setCursor(Qt.CursorShape.PointingHandCursor) + browse_button.setStyleSheet(""" + QPushButton { + border: none; + background: transparent; + padding: 0px; + } + """) + image_path_layout.addWidget(browse_button) + browse_button.clicked.connect(browse_image_path) + + # Enable Image Checkbox + enable_image_container, enable_image_checkbox = create_widget(QCheckBox, "Enable Image (requires image path): ", width=180, height=30) + enable_image_checkbox.setChecked(state.image_enabled) + enable_image_checkbox.toggled.connect(lambda checked: setattr(state, 'image_enabled', checked)) + + # Image Mode Checkbox + image_mode_container, image_mode_checkbox = create_widget(QCheckBox, "Wallpaper Mode: ") + image_mode_checkbox.setChecked(state.image_as_wallpaper) + image_mode_checkbox.toggled.connect(lambda checked: setattr(state, 'image_as_wallpaper', checked)) + + # Image Position Preset + positions = ["bottom-right", "bottom-left", "top-right", "top-left", "center"] + image_position_container, image_position_combo = create_widget(QComboBox, "Position: ", width=180, height=30) + image_position_combo.addItems(positions) + idx = image_position_combo.findText(state.image_position) + image_position_combo.setCurrentIndex(idx if idx >= 0 else 0) + image_position_combo.currentTextChanged.connect(lambda val: setattr(state, 'image_position', val)) + + _slider_style = """ + QSlider::groove:horizontal { + height: 3px; + background: palette(mid); + border-radius: 1px; + } + QSlider::handle:horizontal { + width: 10px; + height: 10px; + margin: -4px 0; + border-radius: 5px; + } + """ + + # Image Width Slider + image_width_container = QWidget() + image_width_layout = QHBoxLayout(image_width_container) + image_width_layout.addWidget(QLabel("Image Width: ")) + image_width = QSlider(Qt.Orientation.Horizontal) + image_width.setFixedWidth(180) + image_width.setStyleSheet(_slider_style) + image_width.setMinimum(0) + image_width.setMaximum(2500) + image_width.setValue(state.image_width) + image_width_val = QLabel(str(state.image_width)) + image_width_val.setFixedWidth(36) + image_width.valueChanged.connect(lambda val: (setattr(state, 'image_width', val), image_width_val.setText(str(val)))) + image_width_layout.addWidget(image_width_val) + image_width_layout.addWidget(image_width) + + # Image Offset Slider + image_offset_container = QWidget() + image_offset_layout = QHBoxLayout(image_offset_container) + image_offset_layout.addWidget(QLabel("Corner Offset: ")) + image_offset = QSlider(Qt.Orientation.Horizontal) + image_offset.setFixedWidth(180) + image_offset.setStyleSheet(_slider_style) + image_offset.setMinimum(0) + image_offset.setMaximum(500) + image_offset.setValue(state.image_offset) + image_offset_val = QLabel(str(state.image_offset)) + image_offset_val.setFixedWidth(36) + image_offset.valueChanged.connect(lambda val: (setattr(state, 'image_offset', val), image_offset_val.setText(str(val)))) + image_offset_layout.addWidget(image_offset_val) + image_offset_layout.addWidget(image_offset) + + # Image Opacity Slider + image_opacity_container = QWidget() + image_opacity_layout = QHBoxLayout(image_opacity_container) + image_opacity_layout.addWidget(QLabel("Image Opacity: ")) + image_opacity = QSlider(Qt.Orientation.Horizontal) + image_opacity.setFixedWidth(180) + image_opacity.setStyleSheet(_slider_style) + image_opacity.setMinimum(0) + image_opacity.setMaximum(100) + image_opacity.setValue(state.image_opacity) + image_opacity_val = QLabel(str(state.image_opacity)) + image_opacity_val.setFixedWidth(36) + image_opacity.valueChanged.connect(lambda val: (setattr(state, 'image_opacity', val), image_opacity_val.setText(str(val)))) + image_opacity_layout.addWidget(image_opacity_val) + image_opacity_layout.addWidget(image_opacity) + + # Speed Limiting + down_speed_limit_container, down_speed_limit = create_widget(QSpinBox, "Max Download Speed (KiB, 0 for unlimited): ", width=180, height=30) + down_speed_limit.setMinimum(0) + down_speed_limit.setValue(state.down_speed_limit) + + up_speed_limit_container, up_speed_limit = create_widget(QSpinBox, "Max Upload Speed (KiB, 0 for unlimited): ", width=180, height=30) + up_speed_limit.setMinimum(0) + up_speed_limit.setValue(state.up_speed_limit) + + # Connection Configs + max_connections_container, max_connections = create_widget(QSpinBox, "Max Connections: ", width=180, height=30) + max_connections.setMinimum(0) + max_connections.setValue(state.max_connections) + + + # Download Configs + max_downloads_container, max_downloads = create_widget(QSpinBox, "Max Downloads: ", width=180, height=30) + max_downloads.setMinimum(0) + max_downloads.setValue(state.max_downloads) + + # Interface Binding + interface_container = QWidget() + interface_layout = QHBoxLayout() + + interface_label = QLabel("Network Interface:") + interface_layout.addWidget(interface_label) + interface_select = QComboBox() + interface_select.addItems(["None"] + state.interfaces) + + target = state.bound_interface if state.bound_interface else "None" + + index = interface_select.findText(target) + if index >= 0: + interface_select.setCurrentIndex(index) + else: + interface_select.setCurrentIndex(0) + + interface_select.setFixedWidth(180) + interface_select.setFixedHeight(30) + interface_layout.addWidget(interface_select) + interface_container.setLayout(interface_layout) + + + # Save / Cancel buttons + layout = QHBoxLayout() + + save_btn = QPushButton("Save") + cancel_btn = QPushButton("Cancel") + save_btn.clicked.connect(lambda: handle_save()) + + def handle_save(): + save_settings( + dialog.accept, api_url.text(), download_path.text(), - down_speed_limit.value(), + down_speed_limit.value(), up_speed_limit.value(), image_path.text(), autoresume_checkbox.isChecked(), max_connections.value(), max_downloads.value(), - interface_select.currentText())) + interface_select.currentText(), + image_width.value(), + image_offset.value(), + image_opacity.value(), + image_as_wallpaper=image_mode_checkbox.isChecked(), + image_position=image_position_combo.currentText(), + accent_color=accent_color_input.text().strip() + ) - cancel_btn.clicked.connect(dialog.reject) - layout.addWidget(cancel_btn) - layout.addWidget(save_btn) + cancel_btn.clicked.connect(dialog.reject) + layout.addWidget(cancel_btn) + layout.addWidget(save_btn) - tabs = QtWidgets.QTabWidget() - create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container], tabs=tabs, stretch=True) - create_tab("Paths", [download_path_container, image_path_container], tabs=tabs, stretch=True) - create_tab("Network", [interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container], tabs=tabs, stretch=True) + tabs = QtWidgets.QTabWidget() + create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container, accent_color_container], tabs=tabs, stretch=True) + create_tab("Image", [enable_image_container, image_mode_container, image_position_container, image_width_container, image_offset_container, image_opacity_container], tabs=tabs, stretch=True) + create_tab("Paths", [download_path_container, image_path_container], tabs=tabs, stretch=True) + create_tab("Network", [interface_container, max_connections_container, max_downloads_container, up_speed_limit_container, down_speed_limit_container, api_url_container], tabs=tabs, stretch=True) - dialog_layout.addWidget(tabs) - dialog_layout.addLayout(layout) - dialog.exec() + dialog_layout.addWidget(tabs) + dialog_layout.addLayout(layout) + + def on_dialog_finished(result): # Undo Image changes if "Save" button is not pressed + if result == QtWidgets.QDialog.DialogCode.Rejected: + state.image_path = temp_image_path + state.image_width = temp_image_width + state.image_offset = temp_image_offset + state.image_opacity = temp_image_opacity + state.image_enabled = temp_image_enabled + state.image_as_wallpaper = temp_image_as_wallpaper + state.image_position = temp_image_position + + dialog.finished.connect(on_dialog_finished) + dialog.exec() diff --git a/src/interface/dialogs/theme.py b/src/interface/dialogs/theme.py index 18011f8..2b23212 100644 --- a/src/interface/dialogs/theme.py +++ b/src/interface/dialogs/theme.py @@ -1,4 +1,6 @@ from PySide6.QtGui import QColor +from utils.logging.logs import consoleLog +from utils.data.state import state import darkdetect def _is_dark_mode(): @@ -27,15 +29,32 @@ def _theme_colors(): } +def _accent_selection_color(alpha: float = 0.35) -> str: + if not state.accent_color: + return None + try: + hex_color = state.accent_color.lstrip('#') + if len(hex_color) != 6: + return None + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + return f"rgba({r}, {g}, {b}, {alpha})" + except ValueError: + consoleLog(f"Invalid Color: {state.accent_color}") + return None + + def _table_stylesheet(view_type="QTableWidget"): c = _theme_colors() dark = _is_dark_mode() color_rule = "" if dark else f"color: {c['text']};" + selected_bg = _accent_selection_color() or c["selected"] + selection_color_rule = f"selection-background-color: {selected_bg};" if state.accent_color else "" return f""" {view_type} {{ border: none; outline: 0; font-size: 13px; + {selection_color_rule} {color_rule} }} {view_type}::item {{ @@ -45,7 +64,7 @@ def _table_stylesheet(view_type="QTableWidget"): {color_rule} }} {view_type}::item:selected {{ - background: {c["selected"]}; + background: {selected_bg}; outline: none; border: none; border-bottom: 1px solid {c["border"]}; diff --git a/src/interface/gui.py b/src/interface/gui.py index 1b048ef..cd1765a 100644 --- a/src/interface/gui.py +++ b/src/interface/gui.py @@ -6,6 +6,7 @@ from interface.assets.base64_icons import settings_white_base64 from interface.assets.base64_icons import settings_black_base64 from interface.dialogs.downloadlist import download_list_update from interface.dialogs.update import get_version, UpdateDialog +from interface.dialogs.theme import _accent_selection_color from utils.logging.logs import consoleLog, flush_log_buffer from interface.dialogs.downloadmodel import DownloadModel from interface.dialogs.settings import settings_dialog @@ -177,6 +178,10 @@ class MainWindow(QtWidgets.QMainWindow, QWidget): self.tracker_list.addItems(list(state.trackers.keys())) self.tracker_list.setCursor(Qt.CursorShape.PointingHandCursor) self.tracker_list.activated.connect(self.set_tracker) + if state.accent_color: + self.tracker_list.setStyleSheet( + f"QComboBox QAbstractItemView::item:selected {{ background: {_accent_selection_color()}; }}" + ) self.corner_layout.addWidget(self.tracker_list) # Settings button diff --git a/src/main.py b/src/main.py index b7b3405..5722bca 100644 --- a/src/main.py +++ b/src/main.py @@ -20,15 +20,18 @@ import signal import time import sys -def run_gui(): - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) - qdarktheme.setup_theme("auto") +def run_gui(app): + custom_colors = {} + if state.accent_color: + custom_colors["primary"] = state.accent_color + qdarktheme.setup_theme("auto", custom_colors=custom_colors if custom_colors else None) widget = MainWindow() # Check OS for window transparency compatibility and apply if state.window_transparency and platform.system() != "Windows": widget.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - qdarktheme.setup_theme("auto", custom_colors={"background": "#00000000"}) + transparent_colors = {"background": "#00000000", **custom_colors} + qdarktheme.setup_theme("auto", custom_colors=transparent_colors) set_main_window(widget) widget.show() @@ -42,7 +45,11 @@ def keyboardinterrupthandler(signum, frame): def main(): # Begin counting startup time start_time = time.perf_counter() - # Parse saved files + + # Initialize UI Engine + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) + + # Parse saved files read_config() logs = get_download_logs() _, downloads = split_data(logs) @@ -65,7 +72,7 @@ def main(): elapsed = time.perf_counter() - start_time consoleLog(f"Initialization completed in {elapsed:.2f}s. Launching GUI") # Launch GUI - run_gui() + run_gui(app) if __name__ == "__main__": main() diff --git a/src/utils/config/config.py b/src/utils/config/config.py index 3036ea8..fc481fe 100644 --- a/src/utils/config/config.py +++ b/src/utils/config/config.py @@ -1,6 +1,9 @@ +from interface.dialogs.notificationpopup import NotificationPopup +from utils.logging.logs import consoleLog from utils.data.state import state import configparser import platform +import time import os @@ -8,24 +11,34 @@ def create_config(): config = configparser.ConfigParser() config["General"] = { - "debug": True, - "ignore_updates": f"{state.ignore_updates}", - "autoresume": f"{state.autoresume}", - "window_transparency": f"{state.window_transparency}" + "debug": str(state.debug), + "ignore_updates": str(state.ignore_updates), + "autoresume": str(state.autoresume), + "window_transparency": str(state.window_transparency), + "accent_color": str(state.accent_color) } config["Network"] = { - "api_url": f"{state.api_url}", - "bound_interface": f"{state.bound_interface}" if state.bound_interface is not None else "None", - "download_speed_limit": f"{state.down_speed_limit}", - "upload_speed_limit": f"{state.up_speed_limit}", - "max_connections": f"{state.max_connections}", - "max_downloads": f"{state.max_downloads}" + "api_url": str(state.api_url), + "bound_interface": str(state.bound_interface) if state.bound_interface is not None else "None", + "download_speed_limit": str(state.down_speed_limit), + "upload_speed_limit": str(state.up_speed_limit), + "max_connections": str(state.max_connections), + "max_downloads": str(state.max_downloads) } config["Paths"] = { - "download_path": f"{state.download_path}", - "image_path": f"{state.image_path}" + "download_path": str(state.download_path), + "image_path": str(state.image_path) + } + + config["Image"] = { + "enable_image": str(state.image_enabled), + "image_width": str(state.image_width), + "image_offset": str(state.image_offset), + "image_opacity": str(state.image_opacity), + "image_as_wallpaper": str(state.image_as_wallpaper), + "image_position": str(state.image_position) } if platform.system() == "Windows": @@ -36,45 +49,102 @@ def create_config(): state.settings_path = os.path.join(config_dir, "SoftwareManager") os.makedirs(state.settings_path, exist_ok=True) - with open(os.path.join(state.settings_path, "config.yml"), 'w') as cf: + with open(os.path.join(state.settings_path, "config.ini"), 'w') as cf: config.write(cf) def read_config(): - config = configparser.ConfigParser() if platform.system() == "Windows": config_dir = os.environ.get("APPDATA", os.path.expanduser("~\\AppData\\Roaming")) else: config_dir = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + state.settings_path = os.path.join(config_dir, "SoftwareManager") - config_file = os.path.join(state.settings_path, "config.yml") + + # Temporary migration logic, keep for a while + old_config_file = os.path.join(state.settings_path, "config.yml") + new_config_file = os.path.join(state.settings_path, "config.ini") - if not os.path.exists(config_file): + if os.path.exists(old_config_file): + try: + os.replace(old_config_file, new_config_file) + consoleLog("Successfully migrated config.yml to config.ini") + except Exception as e: + consoleLog(f"Failed to migrate config file: {e}") + + if not os.path.exists(new_config_file): create_config() return + try: + config.read(new_config_file) + + # General + state.debug = config.getboolean("General", "debug", fallback=state.debug) + state.ignore_updates = config.getboolean("General", "ignore_updates", fallback=state.ignore_updates) + state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume) + state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency) + state.accent_color = config.get("General", "accent_color", fallback=state.accent_color) - config.read(config_file) - - # General - state.debug = config.getboolean("General", "debug", fallback=state.debug) - state.ignore_updates = config.getboolean("General", "ignore_updates", fallback=state.ignore_updates) - state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume) - state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency) + # Network + state.api_url = config.get("Network", "api_url", fallback=state.api_url) + state.bound_interface = config.get("Network", "bound_interface", fallback=state.bound_interface) + if state.bound_interface == "None": + state.bound_interface = None + state.down_speed_limit = config.getint("Network", "download_speed_limit", fallback=state.down_speed_limit) + state.up_speed_limit = config.getint("Network", "upload_speed_limit", fallback=state.up_speed_limit) + state.max_connections = config.getint("Network", "max_connections", fallback=state.max_connections) + state.max_downloads = config.getint("Network", "max_downloads", fallback=state.max_downloads) - # Network - state.api_url = config.get("Network", "api_url", fallback=state.api_url) - state.bound_interface = config.get("Network", "bound_interface", fallback=state.bound_interface) - if state.bound_interface == "None": - state.bound_interface = None - state.down_speed_limit = config.getint("Network", "download_speed_limit", fallback=state.down_speed_limit) - state.up_speed_limit = config.getint("Network", "upload_speed_limit", fallback=state.up_speed_limit) - state.max_connections = config.getint("Network", "max_connections", fallback=state.max_connections) - state.max_downloads = config.getint("Network", "max_downloads", fallback=state.max_downloads) + # Paths + state.download_path = config.get("Paths", "download_path", fallback=state.download_path) + state.image_path = config.get("Paths", "image_path", fallback=state.image_path) - # Paths - state.download_path = config.get("Paths", "download_path", fallback=state.download_path) - state.image_path = config.get("Paths", "image_path", fallback=state.image_path) + # Image + state.image_enabled = config.getboolean("Image", "enable_image", fallback=state.image_enabled) + state.image_width = config.getint("Image", "image_width", fallback=state.image_width) + state.image_offset = config.getint("Image", "image_offset", fallback=state.image_offset) + state.image_opacity = config.getint("Image", "image_opacity", fallback=state.image_opacity) + state.image_as_wallpaper = config.getboolean("Image", "image_as_wallpaper", fallback=state.image_as_wallpaper) + state.image_position = config.get("Image", "image_position", fallback=state.image_position) - create_config() + create_config() + + except configparser.Error as e: + consoleLog(f"Error: Configuration file corrupted ({e}). Resetting to defaults.") + backup_config(new_config_file) # Back up corrupted config + create_config() # Create new config file + NotificationPopup( + title="Config Reset", + text="Your configuration file has been reset due to a structural error.", + infotext="A backup of your old configuration file is available in the settings folder." + ).exec() + + except ValueError as e: + consoleLog(f"Error: Invalid data types in configuration file ({e}). Resetting to defaults.") + backup_config(new_config_file) # Back up corrupted config + create_config() # Create new config file + NotificationPopup( + title="Config Reset", + text="Your configuration file has been reset due to invalid data types.", + infotext="A backup of your old configuration file is available in the settings folder." + ).exec() + + except Exception as e: + consoleLog(f"Unexpected error occurred while loading settings: {e}") + NotificationPopup( + title="Unexpected Error", + text="An unknown error occurred while loading your settings.", + infotext=f"System returned: {e}\n\nThe application may not function as expected." + ).exec() + +def backup_config(config_path): + if os.path.exists(config_path): + timestamp = time.strftime("%Y%m%d-%H%M%S") + backup_path = f"{config_path}.{timestamp}.bak" + try: + os.replace(config_path, backup_path) + consoleLog(f"Successfully created backup of corrupted config: {backup_path}") + except Exception as e: + consoleLog(f"Failed to create backup of corrupted config: {e}") \ No newline at end of file diff --git a/src/utils/config/settings.py b/src/utils/config/settings.py index 1d330fc..09df0da 100644 --- a/src/utils/config/settings.py +++ b/src/utils/config/settings.py @@ -5,7 +5,7 @@ from utils.data.state import state -def save_settings(close=lambda: None, apiurl=None, download_path=None, down_speed_limit=None, up_speed_limit=None, image_path=None, autoresume=None, max_connections=None, max_downloads=None, bound_interface=None): +def save_settings(close=lambda: None, apiurl=None, download_path=None, down_speed_limit=None, up_speed_limit=None, image_path=None, autoresume=None, max_connections=None, max_downloads=None, bound_interface=None, image_width=None, image_offset=None, image_opacity=None, image_as_wallpaper=None, image_position=None, accent_color=None): if apiurl is not None: state.api_url = apiurl if download_path is not None: @@ -24,8 +24,20 @@ def save_settings(close=lambda: None, apiurl=None, download_path=None, down_spee state.max_downloads = max_downloads if bound_interface is not None: state.bound_interface = None if bound_interface == "None" else bound_interface + if image_width is not None: + state.image_width = image_width + if image_offset is not None: + state.image_offset = image_offset + if image_opacity is not None: + state.image_opacity = image_opacity + if image_as_wallpaper is not None: + state.image_as_wallpaper = image_as_wallpaper + if image_position is not None: + state.image_position = image_position + if accent_color is not None: + state.accent_color = accent_color - update_settings() + update_settings() # Update LibTorrent Session Settings consoleLog("Saved Settings") create_config() close() diff --git a/src/utils/data/state.py b/src/utils/data/state.py index 75c3e79..1004d0a 100644 --- a/src/utils/data/state.py +++ b/src/utils/data/state.py @@ -25,11 +25,20 @@ class AppState(QObject): # GUI self.window_transparency: bool = False + self.accent_color: str = "" self.trackertable: QTableWidget self.interfaces: List = [] self.active_interfaces: List = [] self.bound_interface: Any = None + # Image + self._image_enabled: bool = False + self._image_width: int = 300 # Default to 300px + self._image_offset: int = 50 + self._image_opacity: int = 100 + self._image_as_wallpaper: bool = False + self._image_position: str = "bottom-right" # top-left, top-right, bottom-left, bottom-right, center + # Trackers / Scraping self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seeders, leechers self.currenttracker: str = "rutracker" @@ -58,4 +67,94 @@ class AppState(QObject): self._image_path = new_path self.image_changed.emit(new_path) + @property + def image_offset(self) -> int: + return self._image_offset + + @image_offset.setter + def image_offset(self, new_offset: int): + if new_offset != self._image_offset: + self._image_offset = new_offset + self.image_changed.emit(self._image_path) + + @property + def image_width(self) -> int: + return self._image_width + + @image_width.setter + def image_width(self, new_width: int): + if new_width != self._image_width: + self._image_width = new_width + self.image_changed.emit(self._image_path) + + @property + def image_opacity(self) -> int: + return self._image_opacity + + @image_opacity.setter + def image_opacity(self, new_opacity: int): + if new_opacity != self._image_opacity: + self._image_opacity = new_opacity + self.image_changed.emit(self._image_path) + + @property + def image_enabled(self) -> bool: + return self._image_enabled + + @image_enabled.setter + def image_enabled(self, new_state: bool): + if new_state != self._image_enabled: + self._image_enabled = new_state + self.image_changed.emit(self._image_path) + + @property + def image_as_wallpaper(self) -> bool: + return self._image_as_wallpaper + + @image_as_wallpaper.setter + def image_as_wallpaper(self, new_state: bool): + if new_state != self._image_as_wallpaper: + self._image_as_wallpaper = new_state + self.image_changed.emit(self._image_path) + + @property + def image_position(self) -> str: + return self._image_position + + @image_position.setter + def image_position(self, new_pos: str): + if new_pos != self._image_position: + self._image_position = new_pos + self.image_changed.emit(self._image_path) + + @property + def image_custom_position(self) -> bool: + return self._image_custom_position + + @image_custom_position.setter + def image_custom_position(self, new_state: bool): + if new_state != self._image_custom_position: + self._image_custom_position = new_state + self.image_changed.emit(self._image_path) + + @property + def image_x(self) -> int: + return self._image_x + + @image_x.setter + def image_x(self, val: int): + if val != self._image_x: + self._image_x = val + self.image_changed.emit(self._image_path) + + @property + def image_y(self) -> int: + return self._image_y + + @image_y.setter + def image_y(self, val: int): + if val != self._image_y: + self._image_y = val + self.image_changed.emit(self._image_path) + state = AppState() diff --git a/src/utils/logging/loghandler.py b/src/utils/logging/loghandler.py index ae52c8b..f9d5b3d 100644 --- a/src/utils/logging/loghandler.py +++ b/src/utils/logging/loghandler.py @@ -3,7 +3,6 @@ from utils.logging.logs import consoleLog, remove_download_log import os def split_data(data): - count = data.count downloads = data.data