Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 46 additions & 4 deletions negpy/desktop/view/sidebar/export.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,9 +11,12 @@
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMessageBox,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions negpy/desktop/view/styles/modern_dark.qss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 3 additions & 1 deletion negpy/desktop/view/widgets/tutorial_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,9 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
"Choose a format (<b>JPEG</b>, high-bit-depth <b>TIFF</b>, PNG, WebP, JPEG XL, DNG), "
"pick a colour space, and set resolution or print size. The <b>ICC</b> section adds "
"monitor-profile display and soft-proofing.<br><br>"
"<b>Export Presets</b> save named configurations for one-click batch output, and "
"<b>Export Presets</b> 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. "
"<b>Contact Sheet</b> renders all frames into one sheet. Export always runs at full "
"RAW resolution; <b>Export All</b> processes every loaded file."
),
Expand Down
97 changes: 96 additions & 1 deletion tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand Down
Loading