diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index b65f7677..63d37576 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -1563,6 +1563,24 @@ def request_preset_batch_export(self) -> None: sync_metadata = self.state.config.metadata.sync_to_batch visible_files = [self.state.uploaded_files[i] for i in self.session.asset_model.visible_actual_indices_ordered()] + if not visible_files: + return + + n_frames = len(visible_files) + n_presets = len(presets) + n_files = n_frames * n_presets + frame_word = "frame" if n_frames == 1 else "frames" + preset_word = "preset" if n_presets == 1 else "presets" + file_word = "file" if n_files == 1 else "files" + reply = QMessageBox.question( + None, + "Export All Presets", + f"Export {n_frames} {frame_word} through {n_presets} {preset_word} ({n_files} {file_word})?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + if reply != QMessageBox.StandardButton.Yes: + return if self.state.config.export.export_sidecars_enabled: self._write_edit_sidecars(visible_files) diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py index 27e7706f..102d3930 100644 --- a/negpy/desktop/view/sidebar/export.py +++ b/negpy/desktop/view/sidebar/export.py @@ -1,7 +1,7 @@ import os import qtawesome as qta -from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtCore import Qt, QSize, QTimer from PyQt6.QtWidgets import ( QButtonGroup, QCheckBox, @@ -11,9 +11,12 @@ QInputDialog, QLabel, QLineEdit, + QMenu, QMessageBox, QPushButton, + QSizePolicy, QSpinBox, + QToolButton, QVBoxLayout, QWidget, ) @@ -75,6 +78,7 @@ def _connect_signals(self) -> None: self.manage_presets_btn.clicked.connect(self._open_presets_dialog) self.export_presets_btn.clicked.connect(self.controller.request_preset_export) + self.export_presets_menu_btn.clicked.connect(self._show_export_presets_menu) self.intent_btn_group.idToggled.connect(self._on_flat_output_toggled) self.flat_format_combo.currentIndexChanged.connect(self._on_flat_format_changed) @@ -120,12 +124,46 @@ def _add_presets_section(self) -> None: self.manage_presets_btn = QPushButton(" Manage") self.manage_presets_btn.setObjectName("manage_presets_btn") self.manage_presets_btn.setIcon(qta.icon("fa5s.sliders-h", color=THEME.text_primary)) + self.manage_presets_btn.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) + preset_scope_tooltip = ( + "Export the current file with every enabled preset. " + "Use the menu arrow for other scopes." + ) + self.export_presets_group = QWidget() + export_presets_row = QHBoxLayout(self.export_presets_group) + export_presets_row.setContentsMargins(0, 0, 0, 0) + export_presets_row.setSpacing(0) + self.export_presets_btn = QPushButton(" Export Presets") self.export_presets_btn.setObjectName("export_presets_btn") self.export_presets_btn.setIcon(qta.icon("fa5s.layer-group", color=THEME.text_primary)) - self.export_presets_btn.setToolTip("Export the current file with every enabled preset") - preset_btn_row.addWidget(self.manage_presets_btn) - preset_btn_row.addWidget(self.export_presets_btn) + self.export_presets_btn.setToolTip(preset_scope_tooltip) + self.export_presets_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + preset_menu = QMenu(self) + export_current_action = preset_menu.addAction("Export current frame") + export_current_action.setToolTip("Export the current file with every enabled preset") + export_current_action.triggered.connect(self.controller.request_preset_export) + export_all_presets_action = preset_menu.addAction("Export all visible frames…") + export_all_presets_action.setToolTip( + "Export every visible frame in the filmstrip with every enabled preset" + ) + export_all_presets_action.triggered.connect(self.controller.request_preset_batch_export) + + self.export_presets_menu_btn = QToolButton() + self.export_presets_menu_btn.setObjectName("export_presets_menu_btn") + self.export_presets_menu_btn.setAutoRaise(False) + self.export_presets_menu_btn.setIcon(qta.icon("fa5s.chevron-down", color=THEME.text_primary)) + self.export_presets_menu_btn.setIconSize(QSize(18, 18)) + self.export_presets_menu_btn.setFixedWidth(36) + self.export_presets_menu_btn.setToolTip(preset_scope_tooltip) + self._export_presets_menu = preset_menu + + export_presets_row.addWidget(self.export_presets_btn, 1) + export_presets_row.addWidget(self.export_presets_menu_btn, 0) + self.export_presets_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + preset_btn_row.addWidget(self.manage_presets_btn, 0) + preset_btn_row.addWidget(self.export_presets_group, 1) content_layout.addLayout(preset_btn_row) repo = self.controller.session.repo @@ -665,6 +703,10 @@ def _on_preset_toggled(self, idx: int, state: int) -> None: presets[idx].enabled = state == Qt.CheckState.Checked.value self.controller.session.save_export_presets() + def _show_export_presets_menu(self) -> None: + btn = self.export_presets_menu_btn + self._export_presets_menu.exec(btn.mapToGlobal(btn.rect().bottomLeft())) + def _open_presets_dialog(self) -> None: from negpy.desktop.view.widgets.export_presets_dialog import ExportPresetsDialog diff --git a/negpy/desktop/view/styles/modern_dark.qss b/negpy/desktop/view/styles/modern_dark.qss index 06adf237..956c4f94 100644 --- a/negpy/desktop/view/styles/modern_dark.qss +++ b/negpy/desktop/view/styles/modern_dark.qss @@ -259,3 +259,29 @@ QLabel#update_label { font-weight: bold; padding: 5px; } + +QPushButton#export_presets_btn { + text-align: center; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: none; +} + +QToolButton#export_presets_menu_btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 1px solid #3A3A3A; + padding: 6px 4px; +} + +QToolButton#export_presets_menu_btn::menu-button { + width: 0px; + border: none; + padding: 0px; +} + +QToolButton#export_presets_menu_btn::menu-arrow { + image: none; + width: 0px; + height: 0px; +} diff --git a/negpy/desktop/view/widgets/tutorial_steps.py b/negpy/desktop/view/widgets/tutorial_steps.py index cf4cf928..867b5912 100644 --- a/negpy/desktop/view/widgets/tutorial_steps.py +++ b/negpy/desktop/view/widgets/tutorial_steps.py @@ -358,7 +358,9 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]: "Choose a format (JPEG, high-bit-depth TIFF, PNG, WebP, JPEG XL, DNG), " "pick a colour space, and set resolution or print size. The ICC section adds " "monitor-profile display and soft-proofing.

" - "Export Presets save named configurations for one-click batch output, and " + "Export Presets save named configurations for one-click batch output — " + "the main button exports the current frame through every enabled preset; " + "the menu arrow exports all visible frames the same way. " "Contact Sheet renders all frames into one sheet. Export always runs at full " "RAW resolution; Export All processes every loaded file." ), diff --git a/tests/test_controller.py b/tests/test_controller.py index 085c0ea8..9360f927 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -7,7 +7,7 @@ from negpy.desktop.controller import AppController from negpy.desktop.session import DesktopSessionManager, AppState, ToolMode -from negpy.domain.models import ExportConfig, ExportPresetOutputMode +from negpy.domain.models import ExportConfig, ExportFormat, ExportPreset, ExportPresetOutputMode from negpy.services.rendering.preview_manager import PreviewManager if not QApplication.instance(): @@ -409,6 +409,101 @@ def test_export_all_override_settings_applies_current_export_to_all(self): self.assertEqual(t.params.export.export_path, "/tmp/out") +class TestPresetBatchExport(unittest.TestCase): + def setUp(self): + self.mock_session_manager = MagicMock(spec=DesktopSessionManager) + self.mock_session_manager.state = AppState() + self.mock_session_manager.repo = MagicMock() + self.mock_session_manager.repo.load_file_settings.return_value = None + + self.mock_session_manager.state.uploaded_files = [ + {"name": "IMG_0001.cr2", "path": "/tmp/IMG_0001.cr2", "hash": "h1"}, + {"name": "IMG_0002.cr2", "path": "/tmp/IMG_0002.cr2", "hash": "h2"}, + {"name": "scan.tif", "path": "/tmp/scan.tif", "hash": "h3"}, + ] + self.mock_session_manager.state.export_presets = [ + ExportPreset(name="JPEG", enabled=True, export_fmt=ExportFormat.JPEG), + ExportPreset(name="TIFF", enabled=True, export_fmt=ExportFormat.TIFF), + ] + + self.visible_indices = [0, 1, 2] + self.mock_session_manager.asset_model = MagicMock() + self.mock_session_manager.asset_model.visible_actual_indices_ordered.side_effect = lambda: list(self.visible_indices) + + with ( + patch("negpy.desktop.controller.RenderWorker") as mock_rw_class, + patch("negpy.desktop.controller.PreviewManager") as mock_pm_class, + ): + mock_rw_class.return_value = MagicMock() + mock_pm_class.return_value = MagicMock(spec=PreviewManager) + mock_pm_class.return_value.load_linear_preview.return_value = (None, (0, 0), {}) + self.controller = AppController(self.mock_session_manager) + + self.controller._validate_preset_paths = MagicMock(return_value=True) + self.controller._run_export_tasks = MagicMock() + + def tearDown(self): + import gc + + for thread in [ + self.controller.render_thread, + self.controller.export_thread, + self.controller.thumb_thread, + self.controller.norm_thread, + self.controller.discovery_thread, + self.controller.preview_load_thread, + self.controller.scan_thread, + ]: + if thread is not None and thread.isRunning(): + thread.quit() + thread.wait() + del self.controller + gc.collect() + + def _captured_tasks(self): + self.controller._run_export_tasks.assert_called_once() + return self.controller._run_export_tasks.call_args.args[0] + + @patch("negpy.desktop.controller.QMessageBox.question") + def test_preset_batch_export_respects_filter(self, mock_question): + from PyQt6.QtWidgets import QMessageBox + + mock_question.return_value = QMessageBox.StandardButton.Yes + self.visible_indices = [0, 1] + self.controller.request_preset_batch_export() + tasks = self._captured_tasks() + self.assertEqual(len(tasks), 4) + self.assertEqual([t.file_info["name"] for t in tasks], ["IMG_0001.cr2"] * 2 + ["IMG_0002.cr2"] * 2) + + @patch("negpy.desktop.controller.QMessageBox.question") + def test_preset_batch_export_zero_visible_does_not_dispatch(self, mock_question): + self.visible_indices = [] + self.controller.request_preset_batch_export() + self.controller._run_export_tasks.assert_not_called() + mock_question.assert_not_called() + + @patch("negpy.desktop.controller.QMessageBox.question") + def test_preset_batch_export_cancel_does_not_dispatch(self, mock_question): + from PyQt6.QtWidgets import QMessageBox + + mock_question.return_value = QMessageBox.StandardButton.Cancel + self.controller.request_preset_batch_export() + self.controller._run_export_tasks.assert_not_called() + + @patch("negpy.desktop.controller.QMessageBox.question") + def test_preset_batch_export_confirmation_message(self, mock_question): + from PyQt6.QtWidgets import QMessageBox + + mock_question.return_value = QMessageBox.StandardButton.Yes + self.visible_indices = [0, 1, 2] + self.controller.request_preset_batch_export() + mock_question.assert_called_once() + message = mock_question.call_args.args[2] + self.assertIn("3 frames", message) + self.assertIn("2 presets", message) + self.assertIn("6 files", message) + + class TestSessionRestore(unittest.TestCase): def setUp(self): self.mock_session_manager = MagicMock(spec=DesktopSessionManager)