diff --git a/resources/translations/de.json b/resources/translations/de.json index a041f31..b6fc844 100644 --- a/resources/translations/de.json +++ b/resources/translations/de.json @@ -99,6 +99,10 @@ "settings.branding.window_title_label": "Fenstertitel (optional):", "settings.branding.logo_path_label": "Logo/Icon-Datei (optional):", "settings.branding.save_as_btn": "Branding speichern", + "settings.branding.delete_btn": "Branding löschen", + "settings.branding.preview_label": "Vorschau:", + "settings.branding.no_icon_selected": "Kein Icon ausgewählt", + "settings.branding.preview_default_name": "Default", "settings.branding.save_as_title": "Branding speichern", "settings.branding.save_as_prompt": "Name für das Branding eingeben:", "settings.branding.restart_note": "Branding-Änderungen werden persistent gespeichert und nach einem Neustart vollständig angewendet.", diff --git a/resources/translations/en.json b/resources/translations/en.json index 1a471f6..c4e8009 100644 --- a/resources/translations/en.json +++ b/resources/translations/en.json @@ -99,6 +99,10 @@ "settings.branding.window_title_label": "Window title (optional):", "settings.branding.logo_path_label": "Logo/Icon file (optional):", "settings.branding.save_as_btn": "Save Branding", + "settings.branding.delete_btn": "Delete Branding", + "settings.branding.preview_label": "Preview:", + "settings.branding.no_icon_selected": "No icon selected", + "settings.branding.preview_default_name": "Default", "settings.branding.save_as_title": "Save Branding", "settings.branding.save_as_prompt": "Enter a name for the branding:", "settings.branding.restart_note": "Branding changes are persisted immediately and are fully applied after restarting the app.", diff --git a/resources/translations/fr.json b/resources/translations/fr.json index ae6b260..67d9bb3 100644 --- a/resources/translations/fr.json +++ b/resources/translations/fr.json @@ -98,9 +98,13 @@ "settings.branding.app_name_label": "Nom de l’application :", "settings.branding.window_title_label": "Titre de la fenêtre (facultatif) :", "settings.branding.logo_path_label": "Chemin du logo (facultatif) :", - "settings.branding.save_as_btn": "Enregistrer comme modèle", - "settings.branding.save_as_title": "Enregistrer le modèle de branding", - "settings.branding.save_as_prompt": "Entrez un identifiant de modèle (par ex. client_a) :", + "settings.branding.save_as_btn": "Enregistrer le branding", + "settings.branding.delete_btn": "Supprimer le branding", + "settings.branding.preview_label": "Aperçu :", + "settings.branding.no_icon_selected": "Aucune icône sélectionnée", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Enregistrer le branding", + "settings.branding.save_as_prompt": "Entrez un nom pour le branding :", "settings.branding.restart_note": "Les changements de branding sont enregistrés de façon persistante et seront entièrement appliqués après le redémarrage de l’application.", "settings.web_url.label": "URL de l'application web\u00a0:", "settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html", diff --git a/resources/translations/it.json b/resources/translations/it.json index 3d1cb5f..4c0dcf6 100644 --- a/resources/translations/it.json +++ b/resources/translations/it.json @@ -98,9 +98,13 @@ "settings.branding.app_name_label": "Nome applicazione:", "settings.branding.window_title_label": "Titolo finestra (opzionale):", "settings.branding.logo_path_label": "Percorso logo (opzionale):", - "settings.branding.save_as_btn": "Salva come modello", - "settings.branding.save_as_title": "Salva modello di branding", - "settings.branding.save_as_prompt": "Inserisci un ID modello (es. cliente_a):", + "settings.branding.save_as_btn": "Salva branding", + "settings.branding.delete_btn": "Elimina branding", + "settings.branding.preview_label": "Anteprima:", + "settings.branding.no_icon_selected": "Nessuna icona selezionata", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Salva branding", + "settings.branding.save_as_prompt": "Inserisci un nome per il branding:", "settings.branding.restart_note": "Le modifiche al branding vengono salvate in modo persistente e saranno applicate completamente dopo il riavvio dell’applicazione.", "settings.web_url.label": "URL applicazione web:", "settings.web_url.placeholder": "es. http://localhost:8080 o file:///./webapp/index.html", diff --git a/resources/translations/ru.json b/resources/translations/ru.json index 7e17cf0..938956e 100644 --- a/resources/translations/ru.json +++ b/resources/translations/ru.json @@ -98,9 +98,13 @@ "settings.branding.app_name_label": "Имя приложения:", "settings.branding.window_title_label": "Заголовок окна (необязательно):", "settings.branding.logo_path_label": "Путь к логотипу (необязательно):", - "settings.branding.save_as_btn": "Сохранить как шаблон", - "settings.branding.save_as_title": "Сохранить шаблон брендинга", - "settings.branding.save_as_prompt": "Введите ID шаблона (например, client_a):", + "settings.branding.save_as_btn": "Сохранить брендинг", + "settings.branding.delete_btn": "Удалить брендинг", + "settings.branding.preview_label": "Предпросмотр:", + "settings.branding.no_icon_selected": "Значок не выбран", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "Сохранить брендинг", + "settings.branding.save_as_prompt": "Введите имя для брендинга:", "settings.branding.restart_note": "Изменения брендинга сохраняются постоянно и будут полностью применены после перезапуска приложения.", "settings.web_url.label": "URL веб-приложения:", "settings.web_url.placeholder": "например, http://localhost:8080 или file:///./webapp/index.html", diff --git a/resources/translations/zh.json b/resources/translations/zh.json index 85c305b..b263d7e 100644 --- a/resources/translations/zh.json +++ b/resources/translations/zh.json @@ -98,9 +98,13 @@ "settings.branding.app_name_label": "应用名称:", "settings.branding.window_title_label": "窗口标题(可选):", "settings.branding.logo_path_label": "Logo 路径(可选):", - "settings.branding.save_as_btn": "另存为模板", - "settings.branding.save_as_title": "保存品牌模板", - "settings.branding.save_as_prompt": "输入模板 ID(例如 customer_a):", + "settings.branding.save_as_btn": "保存品牌配置", + "settings.branding.delete_btn": "删除品牌配置", + "settings.branding.preview_label": "预览:", + "settings.branding.no_icon_selected": "未选择图标", + "settings.branding.preview_default_name": "Default", + "settings.branding.save_as_title": "保存品牌配置", + "settings.branding.save_as_prompt": "输入品牌名称:", "settings.branding.restart_note": "品牌更改会被持久保存,并将在应用重启后完整生效。", "settings.web_url.label": "Web 应用 URL:", "settings.web_url.placeholder": "例如: http://localhost:8080 或 file:///./webapp/index.html", diff --git a/src/webdrop_bridge/core/branding_manager.py b/src/webdrop_bridge/core/branding_manager.py index d682938..9c01376 100644 --- a/src/webdrop_bridge/core/branding_manager.py +++ b/src/webdrop_bridge/core/branding_manager.py @@ -15,6 +15,7 @@ from webdrop_bridge.config import DEFAULT_CONFIG_DIR_NAME, Config, Configuration logger = logging.getLogger(__name__) DEFAULT_BRANDING_TEMPLATE_ID = "default" +SUPPORTED_LOGO_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".svg", ".ico", ".icns"} @dataclass(frozen=True) @@ -185,6 +186,19 @@ class BrandingManager: logo = logo_path.strip() resolved_app_name = (app_name or display_name).strip() + if not safe_id: + raise ConfigurationError("Branding requires a name") + if not safe_name: + raise ConfigurationError("Branding requires a display name") + if logo: + logo_file = Path(logo) + if not logo_file.exists() or not logo_file.is_file(): + raise ConfigurationError(f"Logo file not found: {logo}") + if logo_file.suffix.lower() not in SUPPORTED_LOGO_SUFFIXES: + raise ConfigurationError( + "Unsupported logo format. Use PNG, JPG, BMP, SVG, ICO, or ICNS." + ) + return BrandingTemplate( template_id=safe_id, display_name=safe_name, diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index cf2d555..9e61e93 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -4,6 +4,7 @@ import logging from pathlib import Path from typing import Any, Dict, Optional +from PySide6.QtGui import QIcon, QPixmap from PySide6.QtWidgets import ( QComboBox, QDialog, @@ -193,6 +194,7 @@ class SettingsDialog(QDialog): self.branding_display_name_input.setPlaceholderText( tr("settings.branding.display_name_label") ) + self.branding_display_name_input.textChanged.connect(self._update_branding_preview) layout.addWidget(QLabel(tr("settings.branding.display_name_label"))) layout.addWidget(self.branding_display_name_input) @@ -200,6 +202,7 @@ class SettingsDialog(QDialog): logo_layout = QHBoxLayout() self.branding_logo_path_input = QLineEdit() self.branding_logo_path_input.setPlaceholderText(tr("settings.branding.logo_path_label")) + self.branding_logo_path_input.textChanged.connect(self._update_branding_preview) logo_layout.addWidget(self.branding_logo_path_input) self.browse_branding_logo_btn = QPushButton(tr("settings.log_file.browse_btn")) @@ -207,10 +210,26 @@ class SettingsDialog(QDialog): logo_layout.addWidget(self.browse_branding_logo_btn) layout.addLayout(logo_layout) + layout.addWidget(QLabel(tr("settings.branding.preview_label"))) + self.branding_preview_name_label = QLabel() + self.branding_preview_name_label.setStyleSheet("font-weight: bold;") + layout.addWidget(self.branding_preview_name_label) + + self.branding_preview_icon_label = QLabel(tr("settings.branding.no_icon_selected")) + self.branding_preview_icon_label.setFixedSize(72, 72) + self.branding_preview_icon_label.setStyleSheet( + "border: 1px solid #ccc; padding: 4px; background: #fafafa;" + ) + layout.addWidget(self.branding_preview_icon_label) + branding_button_layout = QHBoxLayout() self.save_branding_as_btn = QPushButton(tr("settings.branding.save_as_btn")) self.save_branding_as_btn.clicked.connect(self._save_branding_as) branding_button_layout.addWidget(self.save_branding_as_btn) + + self.delete_branding_btn = QPushButton(tr("settings.branding.delete_btn")) + self.delete_branding_btn.clicked.connect(self._delete_branding) + branding_button_layout.addWidget(self.delete_branding_btn) layout.addLayout(branding_button_layout) self._load_branding_into_editor(self.branding_combo.currentData() or "default") @@ -240,10 +259,33 @@ class SettingsDialog(QDialog): self.branding_combo.blockSignals(False) def _load_branding_into_editor(self, template_id: str) -> None: - """Load the selected branding template into the editable fields.""" + """Load the selected branding into the editable fields.""" template = self.branding_manager.load_template(template_id) self.branding_display_name_input.setText(template.display_name) self.branding_logo_path_input.setText(template.logo_path) + self._update_branding_preview() + + def _update_branding_preview(self) -> None: + """Refresh the small branding preview for name and icon.""" + display_name = self.branding_display_name_input.text().strip() or tr( + "settings.branding.preview_default_name" + ) + self.branding_preview_name_label.setText(display_name) + + logo_path = self.branding_logo_path_input.text().strip() + if logo_path and Path(logo_path).exists(): + pixmap = QPixmap(logo_path) + if pixmap.isNull(): + icon = QIcon(logo_path) + pixmap = icon.pixmap(64, 64) + + if not pixmap.isNull(): + self.branding_preview_icon_label.setPixmap(pixmap.scaled(64, 64)) + self.branding_preview_icon_label.setText("") + return + + self.branding_preview_icon_label.setPixmap(QPixmap()) + self.branding_preview_icon_label.setText(tr("settings.branding.no_icon_selected")) def _on_branding_selection_changed(self) -> None: """Update editable branding fields when a different template is selected.""" @@ -275,10 +317,11 @@ class SettingsDialog(QDialog): return try: + display_name = self.branding_display_name_input.text().strip() or branding_name template = self.branding_manager.build_template( template_id=branding_name, - display_name=branding_name, - app_name=branding_name, + display_name=display_name, + app_name=display_name, logo_path=self.branding_logo_path_input.text(), ) self.branding_manager.save_template(template) @@ -287,6 +330,19 @@ class SettingsDialog(QDialog): except ConfigurationError as e: self._show_error(f"Failed to save branding: {e}") + def _delete_branding(self) -> None: + """Delete the currently selected custom branding.""" + template_id = self.branding_combo.currentData() + if not template_id: + return + + try: + self.branding_manager.delete_template(template_id) + self._refresh_branding_combo("default") + self._load_branding_into_editor("default") + except ConfigurationError as e: + self._show_error(f"Failed to delete branding: {e}") + def _create_web_source_tab(self) -> QWidget: """Create web source configuration tab.""" widget = QWidget() diff --git a/tests/unit/test_branding_manager.py b/tests/unit/test_branding_manager.py index 2f08abe..f989365 100644 --- a/tests/unit/test_branding_manager.py +++ b/tests/unit/test_branding_manager.py @@ -1,6 +1,8 @@ """Tests for runtime branding template management.""" -from webdrop_bridge.config import Config +import pytest + +from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.core.branding_manager import BrandingManager @@ -97,3 +99,28 @@ def test_switching_back_to_default_restores_default_branding(tmp_path): assert config.app_name == "WebDrop Bridge" assert config.branding_display_name == "Default" + + +def test_delete_custom_branding_removes_it(tmp_path): + """Custom brandings should be removable while built-ins stay protected.""" + manager = BrandingManager(base_dir=tmp_path) + template = manager.build_template(template_id="Customer B", display_name="Customer B") + manager.save_template(template) + + assert manager.has_template("customer_b") + + manager.delete_template("customer_b") + + assert not manager.has_template("customer_b") + + +def test_invalid_logo_file_is_rejected(tmp_path): + """Non-existent logo files should not be accepted for saved brandings.""" + manager = BrandingManager(base_dir=tmp_path) + + with pytest.raises(ConfigurationError): + manager.build_template( + template_id="customer_c", + display_name="Customer C", + logo_path=str(tmp_path / "missing-logo.png"), + ) diff --git a/tests/unit/test_settings_dialog.py b/tests/unit/test_settings_dialog.py index e7a973d..e2cfd8f 100644 --- a/tests/unit/test_settings_dialog.py +++ b/tests/unit/test_settings_dialog.py @@ -122,6 +122,8 @@ class TestSettingsDialogInitialization: assert dialog.branding_display_name_input.text() == "Default" assert dialog.branding_logo_path_input is not None assert dialog.browse_branding_logo_btn is not None + assert dialog.branding_preview_name_label.text() == "Default" + assert dialog.delete_branding_btn is not None def test_save_branding_as_creates_custom_template( self, qtbot, sample_config, monkeypatch, tmp_path @@ -131,8 +133,11 @@ class TestSettingsDialogInitialization: dialog = SettingsDialog(sample_config) qtbot.addWidget(dialog) + logo_path = tmp_path / "customer-logo.png" + logo_path.write_bytes(b"fake-png-data") + dialog.branding_display_name_input.setText("Customer A") - dialog.branding_logo_path_input.setText("/tmp/customer-logo.png") + dialog.branding_logo_path_input.setText(str(logo_path)) with patch("PySide6.QtWidgets.QInputDialog.getText", return_value=("Customer A", True)): dialog._save_branding_as()