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)