feat: add image opacity, config migration, settings

Add image overlay opacity support and related UI/settings, plus robust config migration and error handling.

Key changes:
- UI: image overlay now uses QGraphicsOpacityEffect, hides when disabled, and updates opacity on state changes (src/interface/dialogs/image.py).
- New NotificationPopup dialog for user alerts (src/interface/dialogs/notificationpopup.py).
- Settings dialog: added Image Opacity control, adjusted labels, persist/rollback behavior on cancel, and include opacity when saving (src/interface/dialogs/settings.py).
- State: added properties for image_opacity and image_enabled with change notifications; renamed image_width setter param for clarity (src/utils/data/state.py).
- Config: switch from config.yml to config.ini, implement one-time migration, typed read/write of values, validation/error handling, automatic backups of corrupted files and user notification (src/utils/config/config.py).
- Settings save: accept and persist image_opacity (src/utils/config/settings.py).
- Startup: initialize QApplication earlier and pass app to run_gui (src/main.py).
- Minor: removed an extra blank line in log handler and small import cleanups.

These changes enable adjustable overlay transparency, improve config reliability, and surface errors to users while preserving/backuping old configs.
This commit is contained in:
Vxrtrauter
2026-04-04 07:36:03 +02:00
parent a3ff23147f
commit 1a7de70c0b
8 changed files with 176 additions and 64 deletions
+10 -3
View File
@@ -1,6 +1,6 @@
from PySide6.QtWidgets import QLabel, QGraphicsOpacityEffect
from PySide6.QtCore import Qt, QSize, QEvent, QObject from PySide6.QtCore import Qt, QSize, QEvent, QObject
from PySide6.QtGui import QImage, QPixmap from PySide6.QtGui import QImage, QPixmap
from PySide6.QtWidgets import QLabel
from utils.data.state import state from utils.data.state import state
import os import os
@@ -11,10 +11,12 @@ class Image(QObject):
self.application = parent self.application = parent
self.overlay_label = QLabel(parent) self.overlay_label = QLabel(parent)
self.overlay_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) self.overlay_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.opacity_effect = QGraphicsOpacityEffect()
self.overlay_label.setGraphicsEffect(self.opacity_effect)
self._current_image_path = None self._current_image_path = None
parent.installEventFilter(self) parent.installEventFilter(self)
state.image_changed.connect(self.update_image_overlay) state.image_changed.connect(self.update_image_overlay)
if state.image_path and os.path.exists(state.image_path): if state.image_path and os.path.exists(state.image_path):
@@ -28,6 +30,7 @@ class Image(QObject):
def _load_and_display(self, image_path): def _load_and_display(self, image_path):
if state.image_enabled is not True: if state.image_enabled is not True:
self.overlay_label.hide()
return return
self._current_image_path = image_path self._current_image_path = image_path
parent = self.application parent = self.application
@@ -56,8 +59,12 @@ class Image(QObject):
x = parent.width() - self.overlay_label.width() - int(state.image_offset) x = parent.width() - self.overlay_label.width() - int(state.image_offset)
y = parent.height() - self.overlay_label.height() - int(state.image_offset) y = parent.height() - self.overlay_label.height() - int(state.image_offset)
self.opacity_effect.setOpacity(state.image_opacity / 100)
self.overlay_label.move(x, y) self.overlay_label.move(x, y)
self.overlay_label.show() self.overlay_label.show()
def update_image_overlay(self, new_image_path): def update_image_overlay(self, new_image_path):
self._load_and_display(new_image_path) self._load_and_display(new_image_path)
@@ -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)
+17 -8
View File
@@ -27,6 +27,8 @@ SVG_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path
def settings_dialog(self): def settings_dialog(self):
temp_image_width = state.image_width temp_image_width = state.image_width
temp_image_offset = state.image_offset temp_image_offset = state.image_offset
temp_image_opacity = state.image_opacity
temp_image_enabled = state.image_enabled
def create_widget(widget_type, label_text, **kwargs): def create_widget(widget_type, label_text, **kwargs):
container = QWidget() container = QWidget()
@@ -70,9 +72,6 @@ def settings_dialog(self):
if state.window_transparency and platform.system() != "Windows" and dialog: if state.window_transparency and platform.system() != "Windows" and dialog:
dialog.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) dialog.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
def close_settings():
dialog.reject()
# Ignore Updates checkbox # Ignore Updates checkbox
update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ") update_checkbox_container, update_checkbox = create_widget(QCheckBox, "Ignore Updates: ")
if platform.system() == "Windows": if platform.system() == "Windows":
@@ -144,7 +143,7 @@ def settings_dialog(self):
browse_button.clicked.connect(browse_image_path) browse_button.clicked.connect(browse_image_path)
# Enable Image Checkbox # Enable Image Checkbox
enable_image_container, enable_image_checkbox = create_widget(QCheckBox, "Enable Image (needs Image Path): ", width=180, height=30) 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.setChecked(state.image_enabled)
enable_image_checkbox.toggled.connect(lambda checked: setattr(state, 'image_enabled', checked)) enable_image_checkbox.toggled.connect(lambda checked: setattr(state, 'image_enabled', checked))
@@ -156,11 +155,18 @@ def settings_dialog(self):
image_width.valueChanged.connect(lambda val: setattr(state, 'image_width', val)) image_width.valueChanged.connect(lambda val: setattr(state, 'image_width', val))
# Image Offset SpinBox # Image Offset SpinBox
image_offset_container, image_offset = create_widget(QSpinBox, "Corner Image Offset: ", width=180, height=30) image_offset_container, image_offset = create_widget(QSpinBox, "Corner Offset: ", width=180, height=30)
image_offset.setMinimum(0) image_offset.setMinimum(0)
image_offset.setValue(state.image_offset) image_offset.setValue(state.image_offset)
image_offset.valueChanged.connect(lambda val: setattr(state, 'image_offset', val)) image_offset.valueChanged.connect(lambda val: setattr(state, 'image_offset', val))
# Image Opacity SpinBox
image_opacity_container, image_opacity = create_widget(QSpinBox, "Image Opacity: ", width=180, height=30)
image_opacity.setMinimum(0)
image_opacity.setMaximum(100)
image_opacity.setValue(state.image_opacity)
image_opacity.valueChanged.connect(lambda val: setattr(state, 'image_opacity', val))
# Speed Limiting # 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_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.setMinimum(0)
@@ -224,7 +230,8 @@ def settings_dialog(self):
max_downloads.value(), max_downloads.value(),
interface_select.currentText(), interface_select.currentText(),
image_width.value(), image_width.value(),
image_offset.value() image_offset.value(),
image_opacity.value()
) )
cancel_btn.clicked.connect(dialog.reject) cancel_btn.clicked.connect(dialog.reject)
@@ -233,7 +240,7 @@ def settings_dialog(self):
tabs = QtWidgets.QTabWidget() tabs = QtWidgets.QTabWidget()
create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container], tabs=tabs, stretch=True) create_tab("General", [autoresume_container, update_checkbox_container, transparent_window_container], tabs=tabs, stretch=True)
create_tab("Image", [enable_image_container, image_width_container, image_offset_container], tabs=tabs, stretch=True) create_tab("Image", [enable_image_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("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) 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)
@@ -241,10 +248,12 @@ def settings_dialog(self):
dialog_layout.addWidget(tabs) dialog_layout.addWidget(tabs)
dialog_layout.addLayout(layout) dialog_layout.addLayout(layout)
def on_dialog_finished(result): def on_dialog_finished(result): # Undo Image changes if "Save" button is not pressed
if result == QtWidgets.QDialog.DialogCode.Rejected: if result == QtWidgets.QDialog.DialogCode.Rejected:
state.image_width = temp_image_width state.image_width = temp_image_width
state.image_offset = temp_image_offset state.image_offset = temp_image_offset
state.image_opacity = temp_image_opacity
state.image_enabled = temp_image_enabled
dialog.finished.connect(on_dialog_finished) dialog.finished.connect(on_dialog_finished)
dialog.exec() dialog.exec()
+7 -4
View File
@@ -20,8 +20,7 @@ import signal
import time import time
import sys import sys
def run_gui(): def run_gui(app):
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
qdarktheme.setup_theme("auto") qdarktheme.setup_theme("auto")
widget = MainWindow() widget = MainWindow()
@@ -42,7 +41,11 @@ def keyboardinterrupthandler(signum, frame):
def main(): def main():
# Begin counting startup time # Begin counting startup time
start_time = time.perf_counter() 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() read_config()
logs = get_download_logs() logs = get_download_logs()
_, downloads = split_data(logs) _, downloads = split_data(logs)
@@ -65,7 +68,7 @@ def main():
elapsed = time.perf_counter() - start_time elapsed = time.perf_counter() - start_time
consoleLog(f"Initialization completed in {elapsed:.2f}s. Launching GUI") consoleLog(f"Initialization completed in {elapsed:.2f}s. Launching GUI")
# Launch GUI # Launch GUI
run_gui() run_gui(app)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+95 -43
View File
@@ -1,6 +1,9 @@
from interface.dialogs.notificationpopup import NotificationPopup
from utils.logging.logs import consoleLog
from utils.data.state import state from utils.data.state import state
import configparser import configparser
import platform import platform
import time
import os import os
@@ -8,30 +11,31 @@ def create_config():
config = configparser.ConfigParser() config = configparser.ConfigParser()
config["General"] = { config["General"] = {
"debug": True, "debug": str(state.debug),
"ignore_updates": f"{state.ignore_updates}", "ignore_updates": str(state.ignore_updates),
"autoresume": f"{state.autoresume}", "autoresume": str(state.autoresume),
"window_transparency": f"{state.window_transparency}" "window_transparency": str(state.window_transparency)
} }
config["Network"] = { config["Network"] = {
"api_url": f"{state.api_url}", "api_url": str(state.api_url),
"bound_interface": f"{state.bound_interface}" if state.bound_interface is not None else "None", "bound_interface": str(state.bound_interface) if state.bound_interface is not None else "None",
"download_speed_limit": f"{state.down_speed_limit}", "download_speed_limit": str(state.down_speed_limit),
"upload_speed_limit": f"{state.up_speed_limit}", "upload_speed_limit": str(state.up_speed_limit),
"max_connections": f"{state.max_connections}", "max_connections": str(state.max_connections),
"max_downloads": f"{state.max_downloads}" "max_downloads": str(state.max_downloads)
} }
config["Paths"] = { config["Paths"] = {
"download_path": f"{state.download_path}", "download_path": str(state.download_path),
"image_path": f"{state.image_path}" "image_path": str(state.image_path)
} }
config["Image"] = { config["Image"] = {
"enable_image": f"{state.image_enabled}", "enable_image": str(state.image_enabled),
"image_width": f"{state.image_width}", "image_width": str(state.image_width),
"image_offset": f"{state.image_offset}" "image_offset": str(state.image_offset),
"image_opacity": str(state.image_opacity)
} }
if platform.system() == "Windows": if platform.system() == "Windows":
@@ -42,51 +46,99 @@ def create_config():
state.settings_path = os.path.join(config_dir, "SoftwareManager") state.settings_path = os.path.join(config_dir, "SoftwareManager")
os.makedirs(state.settings_path, exist_ok=True) 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) config.write(cf)
def read_config(): def read_config():
config = configparser.ConfigParser() config = configparser.ConfigParser()
if platform.system() == "Windows": if platform.system() == "Windows":
config_dir = os.environ.get("APPDATA", os.path.expanduser("~\\AppData\\Roaming")) config_dir = os.environ.get("APPDATA", os.path.expanduser("~\\AppData\\Roaming"))
else: else:
config_dir = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) config_dir = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
state.settings_path = os.path.join(config_dir, "SoftwareManager") 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() create_config()
return 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)
config.read(config_file) # Network
state.api_url = config.get("Network", "api_url", fallback=state.api_url)
# General state.bound_interface = config.get("Network", "bound_interface", fallback=state.bound_interface)
state.debug = config.getboolean("General", "debug", fallback=state.debug) if state.bound_interface == "None":
state.ignore_updates = config.getboolean("General", "ignore_updates", fallback=state.ignore_updates) state.bound_interface = None
state.autoresume = config.getboolean("General", "autoresume", fallback=state.autoresume) state.down_speed_limit = config.getint("Network", "download_speed_limit", fallback=state.down_speed_limit)
state.window_transparency = config.getboolean("General", "window_transparency", fallback=state.window_transparency) 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 # Paths
state.api_url = config.get("Network", "api_url", fallback=state.api_url) state.download_path = config.get("Paths", "download_path", fallback=state.download_path)
state.bound_interface = config.get("Network", "bound_interface", fallback=state.bound_interface) state.image_path = config.get("Paths", "image_path", fallback=state.image_path)
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 # Image
state.download_path = config.get("Paths", "download_path", fallback=state.download_path) state.image_enabled = config.getboolean("Image", "enable_image", fallback=state.image_enabled)
state.image_path = config.get("Paths", "image_path", fallback=state.image_path) 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)
# Image create_config()
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)
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()
create_config() 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}")
+3 -1
View File
@@ -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, image_width=None, image_offset=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):
if apiurl is not None: if apiurl is not None:
state.api_url = apiurl state.api_url = apiurl
if download_path is not None: if download_path is not None:
@@ -28,6 +28,8 @@ def save_settings(close=lambda: None, apiurl=None, download_path=None, down_spee
state.image_width = image_width state.image_width = image_width
if image_offset is not None: if image_offset is not None:
state.image_offset = image_offset state.image_offset = image_offset
if image_opacity is not None:
state.image_opacity = image_opacity
update_settings() # Update LibTorrent Session Settings update_settings() # Update LibTorrent Session Settings
consoleLog("Saved Settings") consoleLog("Saved Settings")
+27 -4
View File
@@ -29,9 +29,12 @@ class AppState(QObject):
self.interfaces: List = [] self.interfaces: List = []
self.active_interfaces: List = [] self.active_interfaces: List = []
self.bound_interface: Any = None self.bound_interface: Any = None
# Image
self._image_enabled: bool = False
self._image_width: int = 300 # Default to 300px self._image_width: int = 300 # Default to 300px
self._image_offset: int = 50 self._image_offset: int = 50
self.image_enabled: bool = False self._image_opacity: int = 100
# Trackers / Scraping # Trackers / Scraping
self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seeders, leechers self.posts: list[Dict[str,str]] | None = None # titles, urls, author, seeders, leechers
@@ -76,9 +79,29 @@ class AppState(QObject):
return self._image_width return self._image_width
@image_width.setter @image_width.setter
def image_width(self, new_offset: int): def image_width(self, new_width: int):
if new_offset != self._image_width: if new_width != self._image_width:
self._image_width = new_offset 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) self.image_changed.emit(self._image_path)
state = AppState() state = AppState()
-1
View File
@@ -3,7 +3,6 @@ from utils.logging.logs import consoleLog, remove_download_log
import os import os
def split_data(data): def split_data(data):
count = data.count count = data.count
downloads = data.data downloads = data.data