import os import socket from datetime import datetime from pathlib import Path import humanize from PyQt6 import QtCore from PyQt6.QtCore import Qt, QSettings, pyqtSignal as Signal, QThread, pyqtSignal, QTimer from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QApplication, QMainWindow, QListWidget, QListWidgetItem, QStackedWidget, QVBoxLayout, \ QWidget, QLabel, QCheckBox, QLineEdit, \ QPushButton, QHBoxLayout, QGroupBox, QTableWidget, QAbstractItemView, QTableWidgetItem, QHeaderView, \ QMessageBox, QProgressBar from src.api.server_proxy import RenderServerProxy from src.engines.engine_manager import EngineManager from src.utilities.config import Config from src.utilities.misc_helper import launch_url from src.version import APP_AUTHOR, APP_NAME settings = QSettings(APP_AUTHOR, APP_NAME) class GetEngineInfoWorker(QThread): """ The GetEngineInfoWorker class fetches engine information from a server in a background thread. Attributes: done: A signal emitted when the engine information is retrieved. Methods: run(self): Fetches engine information from the server. """ done = pyqtSignal(object) # emits the result when finished def __init__(self, parent=None): super().__init__(parent) self.parent = parent def run(self): data = RenderServerProxy(socket.gethostname()).get_all_engine_info() self.done.emit(data) class SettingsWindow(QMainWindow): """ The SettingsWindow class provides a user interface for managing engine settings. """ def __init__(self): super().__init__() self.engine_download_progress_bar = None self.engines_last_update_label = None self.check_for_engine_updates_checkbox = None self.delete_engine_button = None self.launch_engine_button = None self.show_password_button = None self.network_password_line = None self.enable_network_password_checkbox = None self.check_for_new_engines_button = None if not EngineManager.engines_path: # fix issue where sometimes path was not set EngineManager.engines_path = Path(Config.upload_folder).expanduser() / "engines" self.installed_engines_table = None self.setWindowTitle("Settings") # Create the main layout main_layout = QVBoxLayout() # Create the sidebar (QListWidget) for navigation self.sidebar = QListWidget() self.sidebar.setFixedWidth(150) # Set the icon size self.sidebar.setIconSize(QtCore.QSize(32, 32)) # Increase the icon size to 32x32 pixels # Adjust the font size for the sidebar items font = self.sidebar.font() font.setPointSize(12) # Increase the font size self.sidebar.setFont(font) # Add items with icons to the sidebar resources_dir = os.path.join(Path(__file__).resolve().parent.parent.parent, 'resources') self.add_sidebar_item("General", os.path.join(resources_dir, "Gear.png")) self.add_sidebar_item("Server", os.path.join(resources_dir, "Server.png")) self.add_sidebar_item("Engines", os.path.join(resources_dir, "Blender.png")) self.sidebar.setCurrentRow(0) # Create the stacked widget to hold different settings pages self.stacked_widget = QStackedWidget() # Create pages for each section general_page = self.create_general_page() network_page = self.create_network_page() engines_page = self.create_engines_page() # Add pages to the stacked widget self.stacked_widget.addWidget(general_page) self.stacked_widget.addWidget(network_page) self.stacked_widget.addWidget(engines_page) # Connect the sidebar to the stacked widget self.sidebar.currentRowChanged.connect(self.stacked_widget.setCurrentIndex) # Create a horizontal layout to hold the sidebar and stacked widget content_layout = QHBoxLayout() content_layout.addWidget(self.sidebar) content_layout.addWidget(self.stacked_widget) # Add the content layout to the main layout main_layout.addLayout(content_layout) # Add the "OK" button at the bottom ok_button = QPushButton("OK") ok_button.clicked.connect(self.close) ok_button.setFixedWidth(80) ok_button.setDefault(True) main_layout.addWidget(ok_button, alignment=Qt.AlignmentFlag.AlignRight) # Create a central widget and set the layout central_widget = QWidget() central_widget.setLayout(main_layout) self.setCentralWidget(central_widget) self.setMinimumSize(700, 400) # timers for background download UI updates self.timer = QTimer(self) self.timer.timeout.connect(self.update_engine_download_status) def add_sidebar_item(self, name, icon_path): """Add an item with an icon to the sidebar.""" item = QListWidgetItem(QIcon(icon_path), name) self.sidebar.addItem(item) def create_general_page(self): """Create the General settings page.""" page = QWidget() layout = QVBoxLayout() # Startup Settings Group startup_group = QGroupBox("Startup Settings") startup_layout = QVBoxLayout() # startup_layout.addWidget(QCheckBox("Start application on system startup")) check_for_updates_checkbox = QCheckBox("Check for updates automatically") check_for_updates_checkbox.setChecked(settings.value("auto_check_for_updates", True, type=bool)) check_for_updates_checkbox.stateChanged.connect(lambda state: settings.setValue("auto_check_for_updates", bool(state))) startup_layout.addWidget(check_for_updates_checkbox) startup_group.setLayout(startup_layout) # Local Files Group data_path = Path(Config.upload_folder).expanduser() path_size = sum(f.stat().st_size for f in Path(data_path).rglob('*') if f.is_file()) database_group = QGroupBox("Local Files") database_layout = QVBoxLayout() database_layout.addWidget(QLabel(f"Local Directory: {data_path}")) database_layout.addWidget(QLabel(f"Size: {humanize.naturalsize(path_size, binary=True)}")) open_database_path_button = QPushButton("Open Directory") open_database_path_button.clicked.connect(lambda: launch_url(data_path)) open_database_path_button.setFixedWidth(200) database_layout.addWidget(open_database_path_button) database_group.setLayout(database_layout) # Render Settings Group render_settings_group = QGroupBox("Render Engine Settings") render_settings_layout = QVBoxLayout() render_settings_layout.addWidget(QLabel("Restrict to render nodes with same:")) require_same_engine_checkbox = QCheckBox("Renderer Version") require_same_engine_checkbox.setChecked(settings.value("render_require_same_engine_version", False, type=bool)) require_same_engine_checkbox.stateChanged.connect(lambda state: settings.setValue("render_require_same_engine_version", bool(state))) render_settings_layout.addWidget(require_same_engine_checkbox) require_same_cpu_checkbox = QCheckBox("CPU Architecture") require_same_cpu_checkbox.setChecked(settings.value("render_require_same_cpu_type", False, type=bool)) require_same_cpu_checkbox.stateChanged.connect(lambda state: settings.setValue("render_require_same_cpu_type", bool(state))) render_settings_layout.addWidget(require_same_cpu_checkbox) require_same_os_checkbox = QCheckBox("Operating System") require_same_os_checkbox.setChecked(settings.value("render_require_same_os", False, type=bool)) require_same_os_checkbox.stateChanged.connect(lambda state: settings.setValue("render_require_same_os", bool(state))) render_settings_layout.addWidget(require_same_os_checkbox) render_settings_group.setLayout(render_settings_layout) layout.addWidget(startup_group) layout.addWidget(database_group) layout.addWidget(render_settings_group) layout.addStretch() # Add a stretch to push content to the top page.setLayout(layout) return page def create_network_page(self): """Create the Network settings page.""" page = QWidget() layout = QVBoxLayout() # Sharing Settings Group sharing_group = QGroupBox("Sharing Settings") sharing_layout = QVBoxLayout() enable_sharing_checkbox = QCheckBox("Enable other computers on the network to render to this machine") enable_sharing_checkbox.setChecked(settings.value("enable_network_sharing", False, type=bool)) enable_sharing_checkbox.stateChanged.connect(self.toggle_render_sharing) sharing_layout.addWidget(enable_sharing_checkbox) password_enabled = (settings.value("enable_network_sharing", False, type=bool) and settings.value("enable_network_password", False, type=bool)) password_layout = QHBoxLayout() password_layout.setContentsMargins(0, 0, 0, 0) self.enable_network_password_checkbox = QCheckBox("Enable network password:") self.enable_network_password_checkbox.setChecked(settings.value("enable_network_password", False, type=bool)) self.enable_network_password_checkbox.stateChanged.connect(self.enable_network_password_changed) self.enable_network_password_checkbox.setEnabled(settings.value("enable_network_sharing", False, type=bool)) sharing_layout.addWidget(self.enable_network_password_checkbox) self.network_password_line = QLineEdit() self.network_password_line.setPlaceholderText("Enter a password") self.network_password_line.setEchoMode(QLineEdit.EchoMode.Password) self.network_password_line.setEnabled(password_enabled) password_layout.addWidget(self.network_password_line) self.show_password_button = QPushButton("Show") self.show_password_button.setEnabled(password_enabled) self.show_password_button.clicked.connect(self.show_password_button_pressed) password_layout.addWidget(self.show_password_button) sharing_layout.addLayout(password_layout) sharing_group.setLayout(sharing_layout) layout.addWidget(sharing_group) layout.addStretch() # Add a stretch to push content to the top page.setLayout(layout) return page def toggle_render_sharing(self, enable_sharing): settings.setValue("enable_network_sharing", enable_sharing) self.enable_network_password_checkbox.setEnabled(enable_sharing) enable_password = enable_sharing and settings.value("enable_network_password", False, type=bool) self.network_password_line.setEnabled(enable_password) self.show_password_button.setEnabled(enable_password) def enable_network_password_changed(self, new_value): settings.setValue("enable_network_password", new_value) self.network_password_line.setEnabled(new_value) self.show_password_button.setEnabled(new_value) def show_password_button_pressed(self): # toggle showing / hiding the password show_pass = self.show_password_button.text() == "Show" self.show_password_button.setText("Hide" if show_pass else "Show") self.network_password_line.setEchoMode(QLineEdit.EchoMode.Normal if show_pass else QLineEdit.EchoMode.Password) def create_engines_page(self): """Create the Engines settings page.""" page = QWidget() layout = QVBoxLayout() # Installed Engines Group installed_group = QGroupBox("Installed Engines") installed_layout = QVBoxLayout() # Setup table self.installed_engines_table = EngineTableWidget() self.installed_engines_table.row_selected.connect(self.engine_table_selected) installed_layout.addWidget(self.installed_engines_table) # Ignore system installs engine_ignore_system_installs_checkbox = QCheckBox("Ignore system installs") engine_ignore_system_installs_checkbox.setChecked(settings.value("engines_ignore_system_installs", False, type=bool)) engine_ignore_system_installs_checkbox.stateChanged.connect(self.change_ignore_system_installs) installed_layout.addWidget(engine_ignore_system_installs_checkbox) # Engine Launch / Delete buttons installed_buttons_layout = QHBoxLayout() self.launch_engine_button = QPushButton("Launch") self.launch_engine_button.setEnabled(False) self.launch_engine_button.clicked.connect(self.launch_selected_engine) self.delete_engine_button = QPushButton("Delete") self.delete_engine_button.setEnabled(False) self.delete_engine_button.clicked.connect(self.delete_selected_engine) installed_buttons_layout.addWidget(self.launch_engine_button) installed_buttons_layout.addWidget(self.delete_engine_button) installed_layout.addLayout(installed_buttons_layout) installed_group.setLayout(installed_layout) # Engine Updates Group engine_updates_group = QGroupBox("Auto-Install") engine_updates_layout = QVBoxLayout() engine_download_layout = QHBoxLayout() engine_download_layout.addWidget(QLabel("Enable Downloads for:")) at_least_one_downloadable = False for engine in EngineManager.downloadable_engines(): engine_download_check = QCheckBox(engine.name()) is_checked = settings.value(f"engine_download-{engine.name()}", False, type=bool) at_least_one_downloadable |= is_checked engine_download_check.setChecked(is_checked) # Capture the checkbox correctly using a default argument in lambda engine_download_check.clicked.connect( lambda state, checkbox=engine_download_check: self.engine_download_settings_changed(state, checkbox.text()) ) engine_download_layout.addWidget(engine_download_check) engine_updates_layout.addLayout(engine_download_layout) self.check_for_engine_updates_checkbox = QCheckBox("Check for new versions on launch") self.check_for_engine_updates_checkbox.setChecked(settings.value('check_for_engine_updates_on_launch', True, type=bool)) self.check_for_engine_updates_checkbox.setEnabled(at_least_one_downloadable) self.check_for_engine_updates_checkbox.stateChanged.connect( lambda state: settings.setValue("check_for_engine_updates_on_launch", bool(state))) engine_updates_layout.addWidget(self.check_for_engine_updates_checkbox) self.engines_last_update_label = QLabel() self.update_last_checked_label() self.engines_last_update_label.setEnabled(at_least_one_downloadable) engine_updates_layout.addWidget(self.engines_last_update_label) self.engine_download_progress_bar = QProgressBar() engine_updates_layout.addWidget(self.engine_download_progress_bar) self.engine_download_progress_bar.setHidden(True) self.check_for_new_engines_button = QPushButton("Check for New Versions...") self.check_for_new_engines_button.setEnabled(at_least_one_downloadable) self.check_for_new_engines_button.clicked.connect(self.check_for_new_engines) engine_updates_layout.addWidget(self.check_for_new_engines_button) engine_updates_group.setLayout(engine_updates_layout) layout.addWidget(installed_group) layout.addWidget(engine_updates_group) layout.addStretch() # Add a stretch to push content to the top page.setLayout(layout) return page def change_ignore_system_installs(self, value): settings.setValue("engines_ignore_system_installs", bool(value)) self.installed_engines_table.update_engines_table() def update_last_checked_label(self): """Retrieve the last check timestamp and return a human-friendly string.""" last_checked_str = settings.value("engines_last_update_time", None) if not last_checked_str: time_string = "Never" else: last_checked_dt = datetime.fromisoformat(last_checked_str) now = datetime.now() time_string = humanize.naturaltime(now - last_checked_dt) self.engines_last_update_label.setText(f"Last Updated: {time_string}") def engine_download_settings_changed(self, state, engine_name): settings.setValue(f"engine_download-{engine_name}", state) at_least_one_downloadable = False for engine in EngineManager.downloadable_engines(): at_least_one_downloadable |= settings.value(f"engine_download-{engine.name()}", False, type=bool) self.check_for_new_engines_button.setEnabled(at_least_one_downloadable) self.check_for_engine_updates_checkbox.setEnabled(at_least_one_downloadable) self.engines_last_update_label.setEnabled(at_least_one_downloadable) def delete_selected_engine(self): engine_info = self.installed_engines_table.selected_engine_data() reply = QMessageBox.question(self, f"Delete {engine_info['engine']} {engine_info['version']}?", f"Do you want to delete {engine_info['engine']} {engine_info['version']}?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) if reply is not QMessageBox.StandardButton.Yes: return delete_result = EngineManager.delete_engine_download(engine_info.get('engine'), engine_info.get('version'), engine_info.get('system_os'), engine_info.get('cpu')) self.installed_engines_table.update_engines_table(use_cached=False) if delete_result: QMessageBox.information(self, f"{engine_info['engine']} {engine_info['version']} Deleted", f"{engine_info['engine']} {engine_info['version']} deleted successfully", QMessageBox.StandardButton.Ok) else: QMessageBox.warning(self, f"Unknown Error", f"Unknown error while deleting {engine_info['engine']} {engine_info['version']}.", QMessageBox.StandardButton.Ok) def launch_selected_engine(self): engine_info = self.installed_engines_table.selected_engine_data() if engine_info: launch_url(engine_info['path']) def engine_table_selected(self): engine_data = self.installed_engines_table.selected_engine_data() if engine_data: self.launch_engine_button.setEnabled(bool(engine_data.get('path') or True)) self.delete_engine_button.setEnabled(engine_data.get('type') == 'managed') else: self.launch_engine_button.setEnabled(False) self.delete_engine_button.setEnabled(False) def check_for_new_engines(self): ignore_system = settings.value("engines_ignore_system_installs", False, type=bool) messagebox_shown = False for engine in EngineManager.downloadable_engines(): if settings.value(f'engine_download-{engine.name()}', False, type=bool): result = EngineManager.is_engine_update_available(engine, ignore_system_installs=ignore_system) if result: result['name'] = engine.name() msg_box = QMessageBox() msg_box.setWindowTitle(f"{result['name']} ({result['version']}) Available") msg_box.setText(f"A new version of {result['name']} is available ({result['version']}).\n\n" f"Would you like to download it now?") msg_box.setIcon(QMessageBox.Icon.Question) msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) msg_result = msg_box.exec() messagebox_shown = True if msg_result == QMessageBox.StandardButton.Yes: EngineManager.download_engine(engine_name=engine.name(), version=result['version'], background=True, ignore_system=ignore_system) self.engine_download_progress_bar.setHidden(False) self.engine_download_progress_bar.setValue(0) self.engine_download_progress_bar.setMaximum(100) self.check_for_new_engines_button.setEnabled(False) self.timer.start(1000) if not messagebox_shown: msg_box = QMessageBox() msg_box.setWindowTitle("No Updates Available") msg_box.setText("No Updates Available.") msg_box.setIcon(QMessageBox.Icon.Information) msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) msg_box.exec() settings.setValue("engines_last_update_time", datetime.now().isoformat()) self.update_engine_download_status() def update_engine_download_status(self): running_tasks = EngineManager.active_downloads() if not running_tasks: self.timer.stop() self.engine_download_progress_bar.setHidden(True) self.installed_engines_table.update_engines_table(use_cached=False) self.update_last_checked_label() self.check_for_new_engines_button.setEnabled(True) return percent_complete = int(running_tasks[0].percent_complete * 100) self.engine_download_progress_bar.setValue(percent_complete) if percent_complete == 100: status_update = f"Installing {running_tasks[0].engine.capitalize()} {running_tasks[0].version}..." else: status_update = f"Downloading {running_tasks[0].engine.capitalize()} {running_tasks[0].version}..." self.engines_last_update_label.setText(status_update) class EngineTableWidget(QWidget): """ The EngineTableWidget class displays a table of installed engines. Attributes: table: A table widget displaying engine information. Methods: on_selection_changed(self): Emits a signal when the user selects a different row in the table. """ row_selected = Signal() def __init__(self): super().__init__() self.__get_engine_info_worker = None self.table = QTableWidget(0, 4) self.table.setHorizontalHeaderLabels(["Engine", "Version", "Type", "Path"]) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.verticalHeader().setVisible(False) # self.table_widget.itemSelectionChanged.connect(self.engine_picked) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.selectionModel().selectionChanged.connect(self.on_selection_changed) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.table) self.raw_server_data = None def showEvent(self, event): """Runs when the widget is about to be shown.""" self.update_engines_table() super().showEvent(event) # Ensure normal event processing def engine_data_ready(self, raw_server_data): self.raw_server_data = raw_server_data self.update_engines_table() def update_engines_table(self, use_cached=True): if not self.raw_server_data or not use_cached: self.__get_engine_info_worker = GetEngineInfoWorker(self) self.__get_engine_info_worker.done.connect(self.engine_data_ready) self.__get_engine_info_worker.start() if not self.raw_server_data: return table_data = [] # convert the data into a flat list for _, engine_data in self.raw_server_data.items(): table_data.extend(engine_data['versions']) if settings.value("engines_ignore_system_installs", False, type=bool): table_data = [x for x in table_data if x['type'] != 'system'] self.table.clear() self.table.setRowCount(len(table_data)) self.table.setColumnCount(4) self.table.setHorizontalHeaderLabels(['Engine', 'Version', 'Type', 'Path']) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) for row, engine in enumerate(table_data): self.table.setItem(row, 0, QTableWidgetItem(engine['engine'])) self.table.setItem(row, 1, QTableWidgetItem(engine['version'])) self.table.setItem(row, 2, QTableWidgetItem(engine['type'])) self.table.setItem(row, 3, QTableWidgetItem(engine['path'])) self.table.selectRow(0) def selected_engine_data(self): """Returns the data from the selected row as a dictionary.""" row = self.table.currentRow() # Get the selected row index if row < 0 or not len(self.table.selectedItems()): # No row selected return None data = { "engine": self.table.item(row, 0).text(), "version": self.table.item(row, 1).text(), "type": self.table.item(row, 2).text(), "path": self.table.item(row, 3).text(), } return data def on_selection_changed(self): self.row_selected.emit() if __name__ == "__main__": app = QApplication([]) window = SettingsWindow() window.show() app.exec()