webdrop-bridge/src/webdrop_bridge/ui/settings_dialog.py
claudi a48cc01254
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
feat: add language change handling and prompts for restart in settings
2026-03-10 14:38:17 +01:00

647 lines
23 KiB
Python

"""Settings dialog for configuration management."""
import logging
from pathlib import Path
from typing import Any, Dict, Optional
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QPushButton,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
from webdrop_bridge.utils.i18n import get_available_languages, tr
from webdrop_bridge.utils.logging import reconfigure_logging
logger = logging.getLogger(__name__)
class SettingsDialog(QDialog):
"""Dialog for managing application settings and configuration."""
def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the settings dialog.
Args:
config: Current application configuration
parent: Parent widget
"""
super().__init__(parent)
self.config = config
self.profile_manager = ConfigProfile()
self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500)
self.setup_ui()
def setup_ui(self) -> None:
"""Set up the dialog UI with tabs."""
layout = QVBoxLayout()
self.tabs = QTabWidget()
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
self.tabs.addTab(self._create_logging_tab(), tr("settings.tab.logging"))
self.tabs.addTab(self._create_window_tab(), tr("settings.tab.window"))
self.tabs.addTab(self._create_profiles_tab(), tr("settings.tab.profiles"))
layout.addWidget(self.tabs)
button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.setLayout(layout)
def accept(self) -> None:
"""Handle OK button - save configuration changes to file."""
try:
config_data = self.get_config_data()
from webdrop_bridge.config import URLMapping
url_mappings = [
URLMapping(url_prefix=m["url_prefix"], local_path=m["local_path"])
for m in config_data["url_mappings"]
]
old_log_level = self.config.log_level
self.config.language = config_data["language"]
self.config.log_level = config_data["log_level"]
self.config.log_file = (
Path(config_data["log_file"]) if config_data["log_file"] else None
)
self.config.allowed_roots = [Path(r).resolve() for r in config_data["allowed_roots"]]
self.config.allowed_urls = config_data["allowed_urls"]
self.config.webapp_url = config_data["webapp_url"]
self.config.url_mappings = url_mappings
self.config.window_width = config_data["window_width"]
self.config.window_height = config_data["window_height"]
config_path = Config.get_default_config_path()
self.config.to_file(config_path)
logger.info(f"Configuration saved to {config_path}")
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
if old_log_level != self.config.log_level:
reconfigure_logging(
logger_name="webdrop_bridge",
level=self.config.log_level,
log_file=self.config.log_file,
)
logger.info(f"Log level updated to {self.config.log_level}")
super().accept()
except ConfigurationError as e:
logger.error(f"Configuration error: {e}")
self._show_error(f"Configuration Error:\n\n{e}")
except Exception as e:
logger.error(f"Failed to save configuration: {e}", exc_info=True)
self._show_error(f"Failed to save configuration:\n\n{e}")
def _create_general_tab(self) -> QWidget:
"""Create general settings tab with language selector."""
widget = QWidget()
layout = QVBoxLayout()
lang_layout = QHBoxLayout()
lang_layout.addWidget(QLabel(tr("settings.general.language_label")))
self.language_combo = QComboBox()
self.language_combo.addItem(tr("settings.general.language_auto"), "auto")
available = get_available_languages()
current_lang = self.config.language
for code, name in available.items():
self.language_combo.addItem(name, code)
idx = self.language_combo.findData(current_lang)
if idx >= 0:
self.language_combo.setCurrentIndex(idx)
lang_layout.addWidget(self.language_combo)
lang_layout.addStretch()
layout.addLayout(lang_layout)
note = QLabel(tr("settings.general.language_restart_note"))
note.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(note)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_web_source_tab(self) -> QWidget:
"""Create web source configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel(tr("settings.web_source.url_label")))
url_layout = QHBoxLayout()
self.webapp_url_input = QLineEdit()
self.webapp_url_input.setText(self.config.webapp_url)
self.webapp_url_input.setPlaceholderText(
"e.g., http://localhost:8080 or file:///./webapp/index.html"
)
url_layout.addWidget(self.webapp_url_input)
open_btn = QPushButton(tr("settings.web_source.open_btn"))
open_btn.clicked.connect(self._open_webapp_url)
url_layout.addWidget(open_btn)
layout.addLayout(url_layout)
layout.addWidget(QLabel(tr("settings.web_source.url_mappings_label")))
self.url_mappings_table = QTableWidget()
self.url_mappings_table.setColumnCount(2)
self.url_mappings_table.setHorizontalHeaderLabels(
[
tr("settings.web_source.col_url_prefix"),
tr("settings.web_source.col_local_path"),
]
)
self.url_mappings_table.horizontalHeader().setStretchLastSection(True)
for mapping in self.config.url_mappings:
row = self.url_mappings_table.rowCount()
self.url_mappings_table.insertRow(row)
self.url_mappings_table.setItem(row, 0, QTableWidgetItem(mapping.url_prefix))
self.url_mappings_table.setItem(row, 1, QTableWidgetItem(mapping.local_path))
layout.addWidget(self.url_mappings_table)
button_layout = QHBoxLayout()
add_mapping_btn = QPushButton(tr("settings.web_source.add_mapping_btn"))
add_mapping_btn.clicked.connect(self._add_url_mapping)
button_layout.addWidget(add_mapping_btn)
edit_mapping_btn = QPushButton(tr("settings.web_source.edit_mapping_btn"))
edit_mapping_btn.clicked.connect(self._edit_url_mapping)
button_layout.addWidget(edit_mapping_btn)
remove_mapping_btn = QPushButton(tr("settings.web_source.remove_mapping_btn"))
remove_mapping_btn.clicked.connect(self._remove_url_mapping)
button_layout.addWidget(remove_mapping_btn)
layout.addLayout(button_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _open_webapp_url(self) -> None:
"""Open the webapp URL in the default browser."""
import webbrowser
url = self.webapp_url_input.text().strip()
if not url:
return
try:
webbrowser.open(url)
except Exception as e:
logger.error(f"Failed to open URL: {e}")
self._show_error(f"Failed to open URL:\n\n{e}")
def _add_url_mapping(self) -> None:
"""Add new URL mapping."""
from PySide6.QtWidgets import QInputDialog
url_prefix, ok1 = QInputDialog.getText(
self,
tr("settings.web_source.add_mapping_title"),
tr("settings.web_source.add_mapping_url_prompt"),
)
if ok1 and url_prefix:
local_path, ok2 = QInputDialog.getText(
self,
tr("settings.web_source.add_mapping_title"),
tr("settings.web_source.add_mapping_path_prompt"),
)
if ok2 and local_path:
row = self.url_mappings_table.rowCount()
self.url_mappings_table.insertRow(row)
self.url_mappings_table.setItem(row, 0, QTableWidgetItem(url_prefix))
self.url_mappings_table.setItem(row, 1, QTableWidgetItem(local_path))
def _edit_url_mapping(self) -> None:
"""Edit selected URL mapping."""
from PySide6.QtWidgets import QInputDialog
current_row = self.url_mappings_table.currentRow()
if current_row < 0:
self._show_error(tr("settings.web_source.select_mapping_to_edit"))
return
url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore
local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore
new_url_prefix, ok1 = QInputDialog.getText(
self,
tr("settings.web_source.edit_mapping_title"),
tr("settings.web_source.edit_mapping_url_prompt"),
text=url_prefix,
)
if ok1 and new_url_prefix:
new_local_path, ok2 = QInputDialog.getText(
self,
tr("settings.web_source.edit_mapping_title"),
tr("settings.web_source.edit_mapping_path_prompt"),
text=local_path,
)
if ok2 and new_local_path:
self.url_mappings_table.setItem(current_row, 0, QTableWidgetItem(new_url_prefix))
self.url_mappings_table.setItem(current_row, 1, QTableWidgetItem(new_local_path))
def _remove_url_mapping(self) -> None:
"""Remove selected URL mapping."""
current_row = self.url_mappings_table.currentRow()
if current_row >= 0:
self.url_mappings_table.removeRow(current_row)
def _create_paths_tab(self) -> QWidget:
"""Create paths configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel(tr("settings.paths.label")))
self.paths_list = QListWidget()
for path in self.config.allowed_roots:
self.paths_list.addItem(str(path))
layout.addWidget(self.paths_list)
button_layout = QHBoxLayout()
add_path_btn = QPushButton(tr("settings.paths.add_btn"))
add_path_btn.clicked.connect(self._add_path)
button_layout.addWidget(add_path_btn)
remove_path_btn = QPushButton(tr("settings.paths.remove_btn"))
remove_path_btn.clicked.connect(self._remove_path)
button_layout.addWidget(remove_path_btn)
layout.addLayout(button_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_urls_tab(self) -> QWidget:
"""Create URLs configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel(tr("settings.urls.label")))
self.urls_list = QListWidget()
for url in self.config.allowed_urls:
self.urls_list.addItem(url)
layout.addWidget(self.urls_list)
button_layout = QHBoxLayout()
add_url_btn = QPushButton(tr("settings.urls.add_btn"))
add_url_btn.clicked.connect(self._add_url)
button_layout.addWidget(add_url_btn)
remove_url_btn = QPushButton(tr("settings.urls.remove_btn"))
remove_url_btn.clicked.connect(self._remove_url)
button_layout.addWidget(remove_url_btn)
layout.addLayout(button_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_logging_tab(self) -> QWidget:
"""Create logging configuration tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel(tr("settings.logging.level_label")))
self.log_level_combo: QComboBox = self._create_log_level_widget()
layout.addWidget(self.log_level_combo)
layout.addWidget(QLabel(tr("settings.logging.file_label")))
log_file_layout = QHBoxLayout()
self.log_file_input = QLineEdit()
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
log_file_layout.addWidget(self.log_file_input)
browse_btn = QPushButton(tr("settings.logging.browse_btn"))
browse_btn.clicked.connect(self._browse_log_file)
log_file_layout.addWidget(browse_btn)
layout.addLayout(log_file_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_window_tab(self) -> QWidget:
"""Create window settings tab."""
widget = QWidget()
layout = QVBoxLayout()
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel(tr("settings.window.width_label")))
self.width_spin = QSpinBox()
self.width_spin.setMinimum(400)
self.width_spin.setMaximum(5000)
self.width_spin.setValue(self.config.window_width)
width_layout.addWidget(self.width_spin)
width_layout.addStretch()
layout.addLayout(width_layout)
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel(tr("settings.window.height_label")))
self.height_spin = QSpinBox()
self.height_spin.setMinimum(300)
self.height_spin.setMaximum(5000)
self.height_spin.setValue(self.config.window_height)
height_layout.addWidget(self.height_spin)
height_layout.addStretch()
layout.addLayout(height_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_profiles_tab(self) -> QWidget:
"""Create profiles management tab."""
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel(tr("settings.profiles.label")))
self.profiles_list = QListWidget()
self._refresh_profiles_list()
layout.addWidget(self.profiles_list)
button_layout = QHBoxLayout()
save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
save_profile_btn.clicked.connect(self._save_profile)
button_layout.addWidget(save_profile_btn)
load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
load_profile_btn.clicked.connect(self._load_profile)
button_layout.addWidget(load_profile_btn)
delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
delete_profile_btn.clicked.connect(self._delete_profile)
button_layout.addWidget(delete_profile_btn)
layout.addLayout(button_layout)
export_layout = QHBoxLayout()
export_btn = QPushButton(tr("settings.profiles.export_btn"))
export_btn.clicked.connect(self._export_config)
export_layout.addWidget(export_btn)
import_btn = QPushButton(tr("settings.profiles.import_btn"))
import_btn.clicked.connect(self._import_config)
export_layout.addWidget(import_btn)
layout.addLayout(export_layout)
layout.addStretch()
widget.setLayout(layout)
return widget
def _create_log_level_widget(self) -> QComboBox:
"""Create log level selection widget."""
combo = QComboBox()
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
combo.addItems(levels)
combo.setCurrentText(self.config.log_level)
return combo
def _add_path(self) -> None:
"""Add a new allowed path."""
path = QFileDialog.getExistingDirectory(self, tr("settings.paths.select_dir_title"))
if path:
self.paths_list.addItem(path)
def _remove_path(self) -> None:
"""Remove selected path."""
if self.paths_list.currentItem():
self.paths_list.takeItem(self.paths_list.row(self.paths_list.currentItem()))
def _add_url(self) -> None:
"""Add a new allowed URL."""
from PySide6.QtWidgets import QInputDialog
url, ok = QInputDialog.getText(
self, tr("settings.urls.add_title"), tr("settings.urls.add_prompt")
)
if ok and url:
self.urls_list.addItem(url)
def _remove_url(self) -> None:
"""Remove selected URL."""
if self.urls_list.currentItem():
self.urls_list.takeItem(self.urls_list.row(self.urls_list.currentItem()))
def _browse_log_file(self) -> None:
"""Browse for log file location."""
file_path, _ = QFileDialog.getSaveFileName(
self,
tr("settings.logging.select_file_title"),
str(Path.home()),
"Log Files (*.log);;All Files (*)",
)
if file_path:
self.log_file_input.setText(file_path)
def _refresh_profiles_list(self) -> None:
"""Refresh the list of available profiles."""
self.profiles_list.clear()
for profile_name in self.profile_manager.list_profiles():
self.profiles_list.addItem(profile_name)
def _save_profile(self) -> None:
"""Save current configuration as a profile."""
from PySide6.QtWidgets import QInputDialog
profile_name, ok = QInputDialog.getText(
self, tr("settings.profiles.save_title"), tr("settings.profiles.save_prompt")
)
if ok and profile_name:
try:
self.profile_manager.save_profile(profile_name, self.config)
self._refresh_profiles_list()
except ConfigurationError as e:
self._show_error(f"Failed to save profile: {e}")
def _load_profile(self) -> None:
"""Load a saved profile."""
current_item = self.profiles_list.currentItem()
if not current_item:
self._show_error(tr("settings.profiles.select_to_load"))
return
profile_name = current_item.text()
try:
config_data = self.profile_manager.load_profile(profile_name)
self._apply_config_data(config_data)
except ConfigurationError as e:
self._show_error(f"Failed to load profile: {e}")
def _delete_profile(self) -> None:
"""Delete a saved profile."""
current_item = self.profiles_list.currentItem()
if not current_item:
self._show_error(tr("settings.profiles.select_to_delete"))
return
profile_name = current_item.text()
try:
self.profile_manager.delete_profile(profile_name)
self._refresh_profiles_list()
except ConfigurationError as e:
self._show_error(f"Failed to delete profile: {e}")
def _export_config(self) -> None:
"""Export configuration to file."""
file_path, _ = QFileDialog.getSaveFileName(
self,
tr("settings.profiles.export_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)",
)
if file_path:
try:
ConfigExporter.export_to_json(self.config, Path(file_path))
except ConfigurationError as e:
self._show_error(f"Failed to export configuration: {e}")
def _import_config(self) -> None:
"""Import configuration from file."""
file_path, _ = QFileDialog.getOpenFileName(
self,
tr("settings.profiles.import_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)",
)
if file_path:
try:
config_data = ConfigExporter.import_from_json(Path(file_path))
self._apply_config_data(config_data)
except ConfigurationError as e:
self._show_error(f"Failed to import configuration: {e}")
def _apply_config_data(self, config_data: Dict[str, Any]) -> None:
"""Apply imported configuration data to UI.
Args:
config_data: Configuration dictionary
"""
self.paths_list.clear()
for path in config_data.get("allowed_roots", []):
self.paths_list.addItem(str(path))
self.urls_list.clear()
for url in config_data.get("allowed_urls", []):
self.urls_list.addItem(url)
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
log_file = config_data.get("log_file")
self.log_file_input.setText(str(log_file) if log_file else "")
self.width_spin.setValue(config_data.get("window_width", 800))
self.height_spin.setValue(config_data.get("window_height", 600))
language = config_data.get("language", "auto")
idx = self.language_combo.findData(language)
if idx >= 0:
self.language_combo.setCurrentIndex(idx)
def get_config_data(self) -> Dict[str, Any]:
"""Get updated configuration data from dialog.
Returns:
Configuration dictionary
Raises:
ConfigurationError: If configuration is invalid
"""
url_mappings_table_count = (
self.url_mappings_table.rowCount() if self.url_mappings_table else 0
)
config_data = {
"app_name": self.config.app_name,
"app_version": self.config.app_version,
"language": self.language_combo.currentData(),
"log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None,
"allowed_roots": [
self.paths_list.item(i).text() for i in range(self.paths_list.count())
],
"allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())],
"webapp_url": self.webapp_url_input.text().strip(),
"url_mappings": [
{
"url_prefix": (
self.url_mappings_table.item(i, 0).text() # type: ignore
if self.url_mappings_table.item(i, 0)
else ""
),
"local_path": (
self.url_mappings_table.item(i, 1).text() # type: ignore
if self.url_mappings_table.item(i, 1)
else ""
),
}
for i in range(url_mappings_table_count)
],
"window_width": self.width_spin.value(),
"window_height": self.height_spin.value(),
"enable_logging": self.config.enable_logging,
}
ConfigValidator.validate_or_raise(config_data)
return config_data
def _show_error(self, message: str) -> None:
"""Show error message to user.
Args:
message: Error message
"""
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, tr("dialog.error.title"), message)