diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index dfc065a6..5db45bde 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -11,6 +11,7 @@
- Change: **Bundled gear and crosstalk profiles now load live from the app** instead of being copied into your docs folder. Docs folder is reserved for your personal profiles & gear
- Change: **Selecting a thumbnail now opens it** — a single click loads the frame into the viewer (Ctrl/Shift-click still builds a multi-selection for batch actions); double-clicking inside an open crop box now confirms and closes the tool. @linkmodo
- Change: **Cyan** now has an assignable keyboard shortcut alongside the other white-balance sliders, unbound by default (you should not be touching cyan!) — set it in the shortcut editor.
+- Change: **Export UX pass** — one red **Export** button replaces the toolbar Export, "Export All" and the "Sync export settings" toggle; its chevron menu *selects* what the button does (current frame, selected, all visible) and remembers the choice, with **Export Presets** working the same way. Default sizing is now **Original** resolution, multi-frame exports ask for confirmation, and failed frames are reported instead of a false "complete".
- Fix: **crop no longer inflates under Fine Rotation** — the manual crop rectangle is now measured in the same already-rotated view you draw it on, instead of being mapped through the tilt and re-bounded, which grew the cropped area as the tilt increased. @linkmodo
- Fix: **locked/disabled controls now are greyed-out** — sliders, buttons and fields under an active lock (e.g. Analysis Buffer under Roll Average, Flat-Field k1 with no reference profile) now show a grey disabled state instead of looking identical to normal ones.
- Fix: **Next/Prev buttons now follow the sorted/filtered order** shown in the filmstrip instead of raw load order — fixes them greying out mid-roll or staying stuck (but unresponsive) at the end of a roll not loaded alphabetically.
diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py
index 004d4441..337cc539 100644
--- a/negpy/desktop/controller.py
+++ b/negpy/desktop/controller.py
@@ -30,6 +30,7 @@
ExportPresetOutputMode,
ExportResolutionMode,
WorkspaceConfig,
+ export_blocked,
flat_export_config,
flat_master_config,
preset_from_export_config,
@@ -99,7 +100,7 @@ class AppController(QObject):
loading_started = pyqtSignal()
load_failed = pyqtSignal()
export_progress = pyqtSignal(int, int, str)
- export_finished = pyqtSignal(float)
+ export_finished = pyqtSignal(float, int)
render_requested = pyqtSignal(RenderTask)
preview_load_requested = pyqtSignal(PreviewLoadTask)
normalization_requested = pyqtSignal(NormalizationTask)
@@ -139,6 +140,7 @@ def __init__(self, session_manager: DesktopSessionManager):
self._thumb_config: Optional[WorkspaceConfig] = None
self._first_render_t0: Optional[float] = None
self._export_start_time = 0.0
+ self._export_failures = 0
self._discovery_running = False
self._auto_open_after_discovery = False
self._replace_after_discovery = False
@@ -266,6 +268,7 @@ def _connect_signals(self) -> None:
self.export_worker.finished.connect(self._on_export_finished)
self.export_worker.cancelled.connect(self._on_batch_cancelled)
self.export_worker.error.connect(self._on_render_error)
+ self.export_worker.error.connect(self._on_export_task_error)
self.thumbnail_requested.connect(self.thumb_worker.generate)
self.thumb_worker.progress.connect(self._on_thumbnail_progress)
@@ -1560,6 +1563,9 @@ def request_batch_export(self, override_settings: bool = False, files: list[dict
if files is None:
files = [self.state.uploaded_files[i] for i in self.session.asset_model.visible_actual_indices_ordered()]
+ if len(files) > 1 and not self._confirm_bulk_export(f"Export {len(files)} frames?"):
+ return
+
if self.state.config.export.export_sidecars_enabled:
self._write_edit_sidecars(files)
@@ -1655,7 +1661,17 @@ def _build_preset_export_tasks(self, files: list[dict], presets: List[ExportPres
)
return tasks
- def _dispatch_preset_export(self, files: list[dict], *, confirm: bool) -> None:
+ def _confirm_bulk_export(self, text: str) -> bool:
+ reply = QMessageBox.question(
+ None,
+ "Export",
+ text,
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
+ QMessageBox.StandardButton.Cancel,
+ )
+ return reply == QMessageBox.StandardButton.Yes
+
+ def _dispatch_preset_export(self, files: list[dict]) -> None:
if not files:
return
@@ -1667,21 +1683,13 @@ def _dispatch_preset_export(self, files: list[dict], *, confirm: bool) -> None:
if not self._validate_preset_paths(presets):
return
- if confirm:
+ if len(files) > 1:
n_frames = len(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:
+ if not self._confirm_bulk_export(f"Export {n_frames} frames through {n_presets} {preset_word} ({n_files} {file_word})?"):
return
if self.state.config.export.export_sidecars_enabled:
@@ -1701,12 +1709,12 @@ def request_preset_export(self) -> None:
"path": self.state.current_file_path,
"hash": self.state.current_file_hash,
}
- self._dispatch_preset_export([file_info], confirm=False)
+ self._dispatch_preset_export([file_info])
def request_preset_export_selected(self) -> None:
"""Initiates preset export for every selected filmstrip frame."""
files = self._preset_export_files_for_selection()
- self._dispatch_preset_export(files, confirm=False)
+ self._dispatch_preset_export(files)
def request_preset_batch_export(self) -> None:
"""Initiates batch export for all visible files using enabled presets."""
@@ -1715,7 +1723,7 @@ def request_preset_batch_export(self) -> None:
for i in self.session.asset_model.visible_actual_indices_ordered()
if not self.state.uploaded_files[i].get("excluded")
]
- self._dispatch_preset_export(visible_files, confirm=True)
+ self._dispatch_preset_export(visible_files)
def _contact_sheet_output_dir(self, visible_files: list) -> Optional[str]:
"""Resolve the contact sheet output folder (custom path or export destination rules)."""
@@ -1736,6 +1744,9 @@ def request_contact_sheet(self) -> None:
if not out_dir:
return
+ if len(visible_files) > 1 and not self._confirm_bulk_export(f"Render a contact sheet from {len(visible_files)} frames?"):
+ return
+
tasks = []
for f in visible_files:
params = self._batch_params_for(f)
@@ -1751,6 +1762,7 @@ def request_contact_sheet(self) -> None:
cs = self.state.config.export
self._export_start_time = time.time()
+ self._export_failures = 0
self._begin_batch("Contact sheet", abortable=True)
QMetaObject.invokeMethod(
self.export_worker,
@@ -1786,7 +1798,19 @@ def export_edit_sidecars(self) -> None:
self.set_status(f"Wrote {written} edit sidecar(s)", 4000)
def _run_export_tasks(self, tasks: List[ExportTask]) -> None:
+ blocked = [t for t in tasks if export_blocked(t.export_settings.export_fmt, t.export_settings.export_color_space)]
+ if blocked:
+ names = ", ".join(sorted({t.file_info.get("name", "?") for t in blocked})[:5])
+ QMessageBox.warning(
+ None,
+ "Export",
+ f"JPEG XL can't tag the selected colour space ({names}).\n"
+ "Choose sRGB, P3 D65, Rec 2020 or Greyscale, or a different format.",
+ )
+ return
+
self._export_start_time = time.time()
+ self._export_failures = 0
self._begin_batch("Exporting", abortable=True)
QMetaObject.invokeMethod(
self.export_worker,
@@ -1881,10 +1905,13 @@ def _on_render_error(self, message: str) -> None:
self._is_rendering = True
self.render_requested.emit(task)
+ def _on_export_task_error(self, _message: str) -> None:
+ self._export_failures += 1
+
def _on_export_finished(self) -> None:
elapsed = time.time() - self._export_start_time
self._end_batch()
- self.export_finished.emit(elapsed)
+ self.export_finished.emit(elapsed, self._export_failures)
self._update_thumbnail_from_state(force_readback=True)
def _update_thumbnail_from_state(self, force_readback: bool = False, persist: bool = True) -> None:
diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py
index e1263c55..9658c3db 100644
--- a/negpy/desktop/view/canvas/toolbar.py
+++ b/negpy/desktop/view/canvas/toolbar.py
@@ -52,7 +52,6 @@ def _create_separator(self) -> QFrame:
def _init_ui(self) -> None:
main_layout = QHBoxLayout(self)
main_layout.setContentsMargins(0, 10, 0, 10)
- main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
container = QFrame()
container.setObjectName("toolbar_container")
@@ -159,15 +158,10 @@ def _init_ui(self) -> None:
self.canvas_color_btns.append(btn)
self.canvas_color_btns[self.session.state.canvas_bg_index].setChecked(True)
- # 5. Save / Export
- self.btn_save = QPushButton(" Save")
+ # 5. Save
+ self.btn_save = QToolButton()
self.btn_save.setIcon(qta.icon("fa5s.save", color=icon_color))
-
- self.btn_export = QPushButton(" Export")
- self.btn_export.setObjectName("export_btn")
- self.btn_export.setIcon(qta.icon("fa5s.check-circle", color=icon_color))
- self.btn_export.setToolTip("Export Ctrl+E")
- self.btn_export.setFixedHeight(36)
+ self.btn_save.setToolTip("Save Edits")
# 6. Overflow menu & responsive groups
self.btn_overflow = QToolButton()
@@ -258,10 +252,7 @@ def _init_ui(self) -> None:
btn.setFixedHeight(btn_height)
btn.setCursor(Qt.CursorShape.PointingHandCursor)
- self.btn_export.setIconSize(icon_size)
- self.btn_export.setCursor(Qt.CursorShape.PointingHandCursor)
-
- # Single-row layout: toggle_left · prev · next · sep1 · zoom+label · hq · swatches · sep2 · rot_l · rot_r · flip_h · flip_v · sep3 · save · export · overflow · toggle_right
+ # Single-row layout: toggle_left · prev · next · sep1 · zoom+label · hq · swatches · sep2 · rot_l · rot_r · flip_h · flip_v · sep3 · save · overflow · toggle_right
row_layout.addWidget(self.btn_toggle_left)
row_layout.addWidget(self.btn_prev)
row_layout.addWidget(self.btn_next)
@@ -281,7 +272,6 @@ def _init_ui(self) -> None:
self._sep3 = self._create_separator()
row_layout.addWidget(self._sep3)
row_layout.addWidget(self.btn_save)
- row_layout.addWidget(self.btn_export)
row_layout.addWidget(self.btn_compare)
row_layout.addWidget(self.btn_gpu)
row_layout.addWidget(self.btn_overflow)
@@ -292,7 +282,8 @@ def _init_ui(self) -> None:
self._ov_flip_rotate: list = [self.btn_rot_l, self.btn_rot_r, self.btn_flip_h, self.btn_flip_v, self._sep3]
v_layout.addLayout(row_layout)
- main_layout.addWidget(container)
+ # Size the pill to its controls; don't stretch it across the canvas.
+ main_layout.addWidget(container, 0, Qt.AlignmentFlag.AlignCenter)
def _connect_signals(self) -> None:
self.btn_prev.clicked.connect(self.session.prev_file)
@@ -304,7 +295,6 @@ def _connect_signals(self) -> None:
self.btn_flip_v.clicked.connect(lambda: self.flip("vertical"))
self.btn_save.clicked.connect(self.controller.save_current_edits)
- self.btn_export.clicked.connect(self.controller.request_export)
self.canvas_color_group.idToggled.connect(self._on_canvas_color_changed)
diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py
index 2b82dbdd..5274eb18 100644
--- a/negpy/desktop/view/main_window.py
+++ b/negpy/desktop/view/main_window.py
@@ -466,9 +466,12 @@ def _on_export_progress(self, current: int, total: int, filename: str) -> None:
self.top_status.file_pos_label.clear()
self.top_status.showMessage(f"Exporting {filename} ({current}/{total})...")
- def _on_export_finished(self, elapsed: float) -> None:
+ def _on_export_finished(self, elapsed: float, failed: int) -> None:
self.top_status.progress.setVisible(False)
- self.top_status.showMessage(f"export complete in {elapsed:.2f}s", timeout=3000)
+ msg = f"export complete in {elapsed:.2f}s"
+ if failed:
+ msg += f" — {failed} failed"
+ self.top_status.showMessage(msg, timeout=6000 if failed else 3000)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py
index 37fcc212..43667bff 100644
--- a/negpy/desktop/view/sidebar/export.py
+++ b/negpy/desktop/view/sidebar/export.py
@@ -1,7 +1,8 @@
import os
import qtawesome as qta
-from PyQt6.QtCore import Qt, QSize, QTimer
+from PyQt6.QtCore import Qt, QTimer
+from PyQt6.QtGui import QActionGroup
from PyQt6.QtWidgets import (
QButtonGroup,
QCheckBox,
@@ -16,7 +17,6 @@
QPushButton,
QSizePolicy,
QSpinBox,
- QToolButton,
QVBoxLayout,
QWidget,
)
@@ -26,6 +26,7 @@
from negpy.desktop.view.styles.theme import THEME
from negpy.desktop.view.widgets.collapsible import CollapsibleSection
from negpy.desktop.view.widgets.export_settings_form import ExportSettingsForm, constrain_combo
+from negpy.desktop.view.widgets.split_button import make_split_button
from negpy.domain.models import ColorSpace, preset_display_name
from negpy.services.export.contact_sheet_templates import ContactSheetLayout, ContactSheetTemplates
@@ -44,17 +45,17 @@ def _init_ui(self) -> None:
self.update_timer.timeout.connect(self._persist_all_export_settings)
# Task-flow order: the Print/Flat decision reframes the whole form, so it
- # comes first; the form and the primary Export action follow; occasional
- # tools (presets, contact sheet, preview) sit collapsed at the bottom.
+ # comes first with the primary Export action right under it; the form
+ # follows; occasional tools (presets, contact sheet, preview) sit
+ # collapsed at the bottom.
self._add_flat_master_section()
+ self._add_export_section()
# Shared FORMAT / SIZE / COLOR / DESTINATION rows.
self.form = ExportSettingsForm()
self.form.load(self._config_to_form_values())
self.layout.addWidget(self.form)
- self._add_batch_section()
-
self._add_presets_section()
self._add_sidecars_section()
self._add_contact_sheet_section()
@@ -77,8 +78,8 @@ def _connect_signals(self) -> None:
self.controller.monitor_profile_changed.connect(self._refresh_display_info)
self.manage_presets_btn.clicked.connect(self._open_presets_dialog)
- self.export_presets_btn.clicked.connect(self.controller.request_preset_export_selected)
- self.export_presets_menu_btn.clicked.connect(self._show_export_presets_menu)
+ self.export_presets_btn.clicked.connect(self._on_export_presets_clicked)
+ self.export_main_btn.clicked.connect(self._on_export_clicked)
self.intent_btn_group.idToggled.connect(self._on_flat_output_toggled)
self.flat_format_combo.currentIndexChanged.connect(self._on_flat_format_changed)
@@ -87,10 +88,6 @@ def _connect_signals(self) -> None:
self.controller.flat_output_changed.connect(self._on_flat_output_changed)
self.controller.flat_peek_changed.connect(self._on_flat_peek_changed)
- self.apply_all_btn.toggled.connect(self._update_apply_all_style)
- self.batch_export_btn.clicked.connect(
- lambda: self.controller.request_batch_export(override_settings=self.apply_all_btn.isChecked())
- )
self.contact_sheet_btn.clicked.connect(self.controller.request_contact_sheet)
self.cs_save_template_btn.clicked.connect(self._on_save_contact_sheet_template)
self.cs_template_combo.currentTextChanged.connect(self._on_contact_sheet_template_changed)
@@ -125,40 +122,16 @@ def _add_presets_section(self) -> None:
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 selected filmstrip frames with every enabled preset. Use the menu arrow for current frame or all visible frames."
- )
- 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(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)
+ preset_menu, self._preset_scope_actions = self._build_scope_menu(self._PRESET_SCOPES, self._set_preset_scope)
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)
+ self.export_presets_group, self.export_presets_btn, self.export_presets_menu_btn = make_split_button(
+ " Export Presets", "fa5s.layer-group", preset_menu, primary=True
+ )
+ self.export_presets_menu_btn.setToolTip("Choose what the Export Presets button does")
+
+ saved = self.controller.session.repo.get_global_setting("preset_export_scope", "current")
+ self._set_preset_scope(saved if saved in self._PRESET_SCOPES else "current", persist=False)
preset_btn_row.addWidget(self.manage_presets_btn, 0)
preset_btn_row.addWidget(self.export_presets_group, 1)
content_layout.addLayout(preset_btn_row)
@@ -246,8 +219,9 @@ def _labeled_spinbox(label: str, value: int, lo: int, hi: int) -> QSpinBox:
self.contact_sheet_btn = QPushButton(" Export contact sheet")
self.contact_sheet_btn.setObjectName("contact_sheet_btn")
- self.contact_sheet_btn.setFixedHeight(40)
- self.contact_sheet_btn.setIcon(qta.icon("fa5s.th", color=THEME.text_primary))
+ self.contact_sheet_btn.setProperty("primary", True)
+ self.contact_sheet_btn.setFixedHeight(36)
+ self.contact_sheet_btn.setIcon(qta.icon("fa5s.th", color="white"))
self.contact_sheet_btn.setToolTip("Render all visible frames into a contact sheet")
content_layout.addWidget(self.contact_sheet_btn)
@@ -510,8 +484,6 @@ def _sync_flat_enabled(self) -> None:
self.flat_hint_label.setVisible(on)
self.flat_peek_btn.setVisible(on)
self._sync_flat_roll_warning()
- if hasattr(self, "apply_all_btn"):
- self.apply_all_btn.setToolTip(self._apply_all_tooltip_flat if on else self._apply_all_tooltip_print)
if hasattr(self, "form"):
self._refresh_export_enabled()
@@ -629,7 +601,7 @@ def _add_sidecars_section(self) -> None:
self.sidecars_enabled_btn = QPushButton(" Save on export")
self.sidecars_enabled_btn.setCheckable(True)
self.sidecars_enabled_btn.setChecked(conf.export_sidecars_enabled)
- self.sidecars_enabled_btn.setFixedHeight(40)
+ self.sidecars_enabled_btn.setFixedHeight(36)
self.sidecars_enabled_btn.setIcon(qta.icon("fa5s.file-export", color=THEME.text_primary))
self.sidecars_enabled_btn.setToolTip(
"When on, every export also writes a .negpy edit sidecar next to each source frame. Edits stay in the database too."
@@ -638,8 +610,9 @@ def _add_sidecars_section(self) -> None:
self.export_sidecars_btn = QPushButton(" Export sidecars")
self.export_sidecars_btn.setObjectName("export_sidecars_btn")
- self.export_sidecars_btn.setFixedHeight(40)
- self.export_sidecars_btn.setIcon(qta.icon("fa5s.file-code", color=THEME.text_primary))
+ self.export_sidecars_btn.setProperty("primary", True)
+ self.export_sidecars_btn.setFixedHeight(36)
+ self.export_sidecars_btn.setIcon(qta.icon("fa5s.file-code", color="white"))
self.export_sidecars_btn.setToolTip("Write edit sidecars for all visible frames now")
btn_row.addWidget(self.export_sidecars_btn)
@@ -655,24 +628,113 @@ def _add_sidecars_section(self) -> None:
# --- Batch ---------------------------------------------------------------
- def _add_batch_section(self) -> None:
- self.layout.addWidget(section_subheader("BATCH"))
-
- self.apply_all_btn = QPushButton(" Sync export settings")
- self.apply_all_btn.setFixedHeight(40)
- self.apply_all_btn.setCheckable(True)
- self.apply_all_btn.setChecked(True)
- self._apply_all_tooltip_print = "Apply current export settings (format, size, color, destination) to all files"
- self._apply_all_tooltip_flat = "Apply current export settings (size, color, destination) to all files"
- self.apply_all_btn.setToolTip(self._apply_all_tooltip_print)
- self._update_apply_all_style(True)
- self.layout.addWidget(self.apply_all_btn)
-
- self.batch_export_btn = QPushButton(" Export All")
- self.batch_export_btn.setObjectName("batch_export_btn")
- self.batch_export_btn.setFixedHeight(40)
- self.batch_export_btn.setIcon(qta.icon("fa5s.images", color=THEME.text_primary))
- self.layout.addWidget(self.batch_export_btn)
+ # Sticky scopes: the chevron menu picks what a split button does; only the
+ # button itself triggers an export. key -> (menu label, button label, tooltip)
+ _EXPORT_SCOPES = {
+ "current": (
+ "Export current frame",
+ " Export",
+ "Export the current frame with the settings below Ctrl+E",
+ ),
+ "selected": (
+ "Export selected frames",
+ " Export Selected",
+ "Export the selected filmstrip frames, each with its own saved export settings",
+ ),
+ "all_current": (
+ "Export all visible — current settings",
+ " Export All (current settings)",
+ "Export every visible frame using the settings below",
+ ),
+ "all_saved": (
+ "Export all visible — saved per-frame settings",
+ " Export All (saved settings)",
+ "Export every visible frame using each frame's own saved export settings",
+ ),
+ }
+
+ _PRESET_SCOPES = {
+ "current": (
+ "Export current frame",
+ " Export Presets",
+ "Export the current frame with every enabled preset",
+ ),
+ "selected": (
+ "Export selected frames",
+ " Export Presets (selected)",
+ "Export the selected filmstrip frames with every enabled preset",
+ ),
+ "all": (
+ "Export all visible frames",
+ " Export Presets (all)",
+ "Export every visible frame with every enabled preset",
+ ),
+ }
+
+ def _build_scope_menu(self, scopes: dict, on_select) -> tuple[QMenu, dict]:
+ """Checkable, exclusive scope menu for a sticky split button."""
+ menu = QMenu(self)
+ group = QActionGroup(menu)
+ group.setExclusive(True)
+ actions = {}
+ for key, (label, _btn_label, tooltip) in scopes.items():
+ act = menu.addAction(label)
+ act.setCheckable(True)
+ act.setToolTip(tooltip)
+ act.triggered.connect(lambda _checked=False, k=key: on_select(k))
+ group.addAction(act)
+ actions[key] = act
+ return menu, actions
+
+ def _add_export_section(self) -> None:
+ """Primary Export action: the menu arrow selects the sticky scope."""
+ menu, self._export_scope_actions = self._build_scope_menu(self._EXPORT_SCOPES, self._set_export_scope)
+ self._export_menu = menu
+
+ container, self.export_main_btn, self.export_menu_btn = make_split_button(" Export", "fa5s.check-circle", menu, primary=True)
+ self.export_menu_btn.setToolTip("Choose what the Export button does")
+ self.layout.addWidget(container)
+
+ saved = self.controller.session.repo.get_global_setting("export_scope", "current")
+ self._set_export_scope(saved if saved in self._EXPORT_SCOPES else "current", persist=False)
+
+ def _set_export_scope(self, key: str, persist: bool = True) -> None:
+ self._export_scope = key
+ _label, btn_label, tooltip = self._EXPORT_SCOPES[key]
+ self._export_scope_actions[key].setChecked(True)
+ self.export_main_btn.setText(btn_label)
+ self.export_main_btn.setToolTip(tooltip)
+ if persist:
+ self.controller.session.repo.save_global_setting("export_scope", key)
+
+ def _on_export_clicked(self) -> None:
+ scope = self._export_scope
+ if scope == "selected":
+ self.controller.request_export_selected()
+ elif scope == "all_current":
+ self.controller.request_batch_export(override_settings=True)
+ elif scope == "all_saved":
+ self.controller.request_batch_export(override_settings=False)
+ else:
+ self.controller.request_export()
+
+ def _set_preset_scope(self, key: str, persist: bool = True) -> None:
+ self._preset_scope = key
+ _label, btn_label, tooltip = self._PRESET_SCOPES[key]
+ self._preset_scope_actions[key].setChecked(True)
+ self.export_presets_btn.setText(btn_label)
+ self.export_presets_btn.setToolTip(tooltip)
+ if persist:
+ self.controller.session.repo.save_global_setting("preset_export_scope", key)
+
+ def _on_export_presets_clicked(self) -> None:
+ scope = self._preset_scope
+ if scope == "selected":
+ self.controller.request_preset_export_selected()
+ elif scope == "all":
+ self.controller.request_preset_batch_export()
+ else:
+ self.controller.request_preset_export()
def _rebuild_preset_rows(self) -> None:
"""Rebuild the preset checkbox list from state."""
@@ -700,10 +762,6 @@ 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
@@ -718,23 +776,6 @@ def _on_presets_changed(self, presets: list) -> None:
# --- Current export settings ---------------------------------------------
- def _update_apply_all_style(self, checked: bool) -> None:
- """Toggle checked appearance for the Sync export settings button."""
- if checked:
- self.apply_all_btn.setStyleSheet("""
- QPushButton {
- background-color: #222222;
- color: white;
- font-weight: bold;
- border: 2px solid #555555;
- border-radius: 4px;
- }
- """)
- self.apply_all_btn.setIcon(qta.icon("fa5s.clone", color="white"))
- else:
- self.apply_all_btn.setStyleSheet("font-weight: bold;")
- self.apply_all_btn.setIcon(qta.icon("fa5s.clone", color=THEME.text_primary))
-
def _config_to_form_values(self) -> dict:
"""Build the form's value dict from the export config + ICC AppState."""
conf = self.state.config.export
@@ -834,9 +875,11 @@ def _refresh_proof_mismatch_warning(self) -> None:
self.proof_mismatch_label.setVisible(mismatch)
def _refresh_export_enabled(self) -> None:
- """Disable 'Export All' when the current format/colour-space pairing can't
- be encoded (JPEG XL only tags a subset of colour spaces)."""
- self.batch_export_btn.setEnabled(not self.form.is_export_blocked())
+ """Disable the Export action when the current format/colour-space pairing
+ can't be encoded (JPEG XL only tags a subset of colour spaces)."""
+ blocked = self.form.is_export_blocked()
+ self.export_main_btn.setEnabled(not blocked)
+ self.export_menu_btn.setEnabled(not blocked)
def sync_ui(self) -> None:
conf = self.state.config.export
diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py
index 3255da09..e8c28b7e 100644
--- a/negpy/desktop/view/sidebar/files.py
+++ b/negpy/desktop/view/sidebar/files.py
@@ -531,9 +531,9 @@ def _build_context_menu(self) -> QMenu:
menu = QMenu(self)
if multi:
- menu.addAction("Export Selected").triggered.connect(lambda: self.controller.request_export_selected())
+ menu.addAction("Export selected frames").triggered.connect(lambda: self.controller.request_export_selected())
else:
- menu.addAction("Export").triggered.connect(lambda: self.controller.request_export())
+ menu.addAction("Export current frame").triggered.connect(lambda: self.controller.request_export())
menu.addSeparator()
menu.addAction("Copy Settings Ctrl+C").triggered.connect(self.session.copy_settings)
menu.addAction("Copy Settings + Bounds Ctrl+Shift+C").triggered.connect(self.session.copy_settings_with_bounds)
diff --git a/negpy/desktop/view/styles/modern_dark.qss b/negpy/desktop/view/styles/modern_dark.qss
index b0e96dae..c1bacf67 100644
--- a/negpy/desktop/view/styles/modern_dark.qss
+++ b/negpy/desktop/view/styles/modern_dark.qss
@@ -279,27 +279,58 @@ QLabel#update_label {
padding: 5px;
}
-QPushButton#export_presets_btn {
+/* Primary action (Export in the Export panel): solid accent fill so it reads
+ as the one call-to-action, not a disabled outline. */
+QPushButton[primary="true"], QToolButton[primary="true"] {
+ background-color: #B71C1C;
+ border: 1px solid #B71C1C;
+ color: #FFFFFF;
+ font-weight: 600;
+}
+
+QPushButton[primary="true"]:hover, QToolButton[primary="true"]:hover {
+ background-color: #C62828;
+ border: 1px solid #C62828;
+}
+
+QPushButton[primary="true"]:pressed, QToolButton[primary="true"]:pressed {
+ background-color: #8E1414;
+ border: 1px solid #8E1414;
+}
+
+QPushButton[primary="true"]:disabled, QToolButton[primary="true"]:disabled {
+ background-color: transparent;
+ border: 1px solid #262626;
+ color: #555555;
+}
+
+/* Darker red divider between the two halves of a primary split button. */
+QToolButton#split_menu_btn[primary="true"] {
+ border-left: 1px solid #8E1414;
+}
+
+/* Fused split button (main action + menu arrow), see widgets/split_button.py. */
+QPushButton#split_main_btn {
text-align: center;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
}
-QToolButton#export_presets_menu_btn {
+QToolButton#split_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 {
+QToolButton#split_menu_btn::menu-button {
width: 0px;
border: none;
padding: 0px;
}
-QToolButton#export_presets_menu_btn::menu-arrow {
+QToolButton#split_menu_btn::menu-arrow {
image: none;
width: 0px;
height: 0px;
diff --git a/negpy/desktop/view/widgets/export_settings_form.py b/negpy/desktop/view/widgets/export_settings_form.py
index ac633d1b..59e8d90a 100644
--- a/negpy/desktop/view/widgets/export_settings_form.py
+++ b/negpy/desktop/view/widgets/export_settings_form.py
@@ -23,11 +23,13 @@
from negpy.desktop.view.styles.theme import THEME
from negpy.desktop.view.widgets.sliders import CompactSlider
from negpy.domain.models import (
+ JXL_TAGGABLE_SPACES,
AspectRatio,
ColorSpace,
ExportFormat,
ExportPresetOutputMode,
ExportResolutionMode,
+ export_blocked,
)
from negpy.infrastructure.display.color_mgmt import ColorService
from negpy.infrastructure.display.color_spaces import ColorSpaceRegistry
@@ -43,17 +45,6 @@ def constrain_combo(combo: QComboBox, min_chars: int = 6) -> None:
combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
-# Spaces JXL can tag (mirror _JXL_COLOR). Same as Source is allowed — resolved at
-# export time and rejected by the encoder if it lands on an unsupported space.
-_JXL_SUPPORTED = {
- ColorSpace.SRGB.value,
- ColorSpace.P3_D65.value,
- ColorSpace.REC2020.value,
- ColorSpace.GREYSCALE.value,
- ColorSpace.SAME_AS_SOURCE.value,
-}
-
-
class ExportSettingsForm(QWidget):
"""Shared FORMAT / SIZE / COLOR / DESTINATION rows for the export sidebar and
the presets dialog. Emits ``changed`` on any edit; the parent owns persistence.
@@ -360,9 +351,9 @@ def _apply_jxl_constraints(self) -> None:
for i in range(self.color_space_combo.count()):
item = model.item(i)
if item is not None:
- supported = self.color_space_combo.itemText(i) in _JXL_SUPPORTED
+ supported = self.color_space_combo.itemText(i) in JXL_TAGGABLE_SPACES
item.setEnabled(supported or not is_jxl)
- if is_jxl and self.color_space_combo.currentText() not in _JXL_SUPPORTED:
+ if is_jxl and self.color_space_combo.currentText() not in JXL_TAGGABLE_SPACES:
self.color_space_combo.setCurrentText(ColorSpace.SRGB.value)
if is_jxl:
@@ -377,7 +368,7 @@ def is_export_blocked(self) -> bool:
"""True when the current JXL + colour space pairing can't be tagged."""
if self._flat_mode:
return False
- return self.fmt_combo.currentText() == ExportFormat.JXL and self.color_space_combo.currentText() not in _JXL_SUPPORTED
+ return export_blocked(self.fmt_combo.currentText(), self.color_space_combo.currentText())
def _refresh_jxl_warning(self) -> None:
blocked = self.is_export_blocked()
diff --git a/negpy/desktop/view/widgets/split_button.py b/negpy/desktop/view/widgets/split_button.py
new file mode 100644
index 00000000..7cf9e475
--- /dev/null
+++ b/negpy/desktop/view/widgets/split_button.py
@@ -0,0 +1,40 @@
+import qtawesome as qta
+from PyQt6.QtCore import QSize
+from PyQt6.QtWidgets import QHBoxLayout, QMenu, QPushButton, QSizePolicy, QToolButton, QWidget
+
+from negpy.desktop.view.styles.theme import THEME
+
+
+def make_split_button(text: str, icon_name: str, menu: QMenu, *, primary: bool = False) -> tuple[QWidget, QPushButton, QToolButton]:
+ """Fused main-action button + menu-arrow pair, styled via the shared
+ split_main_btn / split_menu_btn QSS rules. ``primary`` marks both halves
+ with the accent [primary="true"] style."""
+ container = QWidget()
+ row = QHBoxLayout(container)
+ row.setContentsMargins(0, 0, 0, 0)
+ row.setSpacing(0)
+
+ icon_color = "white" if primary else THEME.text_primary
+ main_btn = QPushButton(text)
+ main_btn.setObjectName("split_main_btn")
+ main_btn.setIcon(qta.icon(icon_name, color=icon_color))
+ main_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ main_btn.setFixedHeight(36)
+
+ menu_btn = QToolButton()
+ menu_btn.setObjectName("split_menu_btn")
+ menu_btn.setAutoRaise(False)
+ menu_btn.setIcon(qta.icon("fa5s.chevron-down", color=icon_color))
+ menu_btn.setIconSize(QSize(18, 18))
+ menu_btn.setFixedWidth(36)
+ menu_btn.setFixedHeight(36)
+ menu_btn.clicked.connect(lambda: menu.exec(menu_btn.mapToGlobal(menu_btn.rect().bottomLeft())))
+
+ if primary:
+ main_btn.setProperty("primary", True)
+ menu_btn.setProperty("primary", True)
+
+ row.addWidget(main_btn, 1)
+ row.addWidget(menu_btn, 0)
+ container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ return container, main_btn, menu_btn
diff --git a/negpy/desktop/view/widgets/tutorial_steps.py b/negpy/desktop/view/widgets/tutorial_steps.py
index 685870ef..43252e49 100644
--- a/negpy/desktop/view/widgets/tutorial_steps.py
+++ b/negpy/desktop/view/widgets/tutorial_steps.py
@@ -362,11 +362,11 @@ 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 — "
- "the main button exports the selected filmstrip frames through every enabled preset; "
- "the menu arrow offers the current frame or all visible frames. "
- "Contact Sheet renders all frames into one sheet. Export always runs at full "
- "RAW resolution; Export All processes every loaded file."
+ "The Export and Export Presets buttons are triggers; each button's "
+ "menu arrow picks what it exports (current frame, selected frames, or all visible "
+ "frames) and remembers the choice. Presets run every enabled preset per frame. "
+ "Contact Sheet renders all frames into one sheet. "
+ "Export always runs at full RAW resolution."
),
target=_export,
pre_hook=lambda w: w.right_panel.show_tab_by_key("export"),
diff --git a/negpy/domain/models.py b/negpy/domain/models.py
index e9069097..851feb19 100644
--- a/negpy/domain/models.py
+++ b/negpy/domain/models.py
@@ -92,6 +92,26 @@ class ColorSpace(Enum):
GREYSCALE = "Greyscale"
+# Colour spaces JPEG XL can tag (mirror _JXL_COLOR in image_processor). Same as
+# Source is allowed — resolved at export time and rejected by the encoder if it
+# lands on an unsupported space.
+JXL_TAGGABLE_SPACES = frozenset(
+ {
+ ColorSpace.SRGB.value,
+ ColorSpace.P3_D65.value,
+ ColorSpace.REC2020.value,
+ ColorSpace.GREYSCALE.value,
+ ColorSpace.SAME_AS_SOURCE.value,
+ }
+)
+
+
+def export_blocked(fmt: str, color_space: str) -> bool:
+ """True when the format + colour-space pairing can't be encoded (JPEG XL
+ only tags a subset of colour spaces)."""
+ return fmt == ExportFormat.JXL and color_space not in JXL_TAGGABLE_SPACES
+
+
@dataclass(frozen=True)
class ExportConfig:
"""
@@ -113,7 +133,7 @@ class ExportConfig:
paper_aspect_ratio: str = AspectRatio.ORIGINAL
export_print_size: float = 30.0
export_dpi: int = 300
- export_resolution_mode: str = ExportResolutionMode.PRINT.value
+ export_resolution_mode: str = ExportResolutionMode.ORIGINAL.value
export_target_long_edge_px: int = 2000
filename_pattern: str = "{{ original_name }}"
overwrite: bool = True
diff --git a/tests/test_controller.py b/tests/test_controller.py
index 059da7b9..b3be54d0 100644
--- a/tests/test_controller.py
+++ b/tests/test_controller.py
@@ -351,6 +351,7 @@ def setUp(self):
self.controller._ensure_valid_export_path = MagicMock(return_value="/tmp/out")
self.controller._run_export_tasks = MagicMock()
+ self.controller._confirm_bulk_export = MagicMock(return_value=True)
def tearDown(self):
import gc
@@ -558,11 +559,14 @@ def tearDown(self):
del self.controller
gc.collect()
- def test_preset_export_selected_uses_display_order_without_confirmation(self):
+ def test_preset_export_selected_confirms_and_uses_display_order(self):
+ from PyQt6.QtWidgets import QMessageBox
+
self.mock_session_manager.state.selected_indices = [2, 0]
with patch("negpy.desktop.controller.QMessageBox.question") as mock_question:
+ mock_question.return_value = QMessageBox.StandardButton.Yes
self.controller.request_preset_export_selected()
- mock_question.assert_not_called()
+ mock_question.assert_called_once()
tasks = self.controller._run_export_tasks.call_args.args[0]
self.assertEqual(len(tasks), 4)
diff --git a/tests/test_export_jxl.py b/tests/test_export_jxl.py
index f67a5513..30d710b1 100644
--- a/tests/test_export_jxl.py
+++ b/tests/test_export_jxl.py
@@ -3,10 +3,12 @@
import pytest
from negpy.domain.models import (
+ JXL_TAGGABLE_SPACES,
ColorSpace,
ExportConfig,
ExportFormat,
ExportPreset,
+ export_blocked,
preset_from_export_config,
)
from negpy.kernel.image.logic import float_to_uint16, float_to_uint_luma
@@ -104,3 +106,12 @@ def test_jxl_fields_roundtrip_through_preset():
restored = ExportPreset.from_dict(preset.to_dict())
assert (restored.jxl_lossless, restored.jxl_distance, restored.jxl_effort) == (False, 2.5, 9)
+
+
+def test_export_blocked_pure_helper_mirrors_jxl_table():
+ """The UI/controller gate must stay in sync with the encoder's tag table."""
+ assert JXL_TAGGABLE_SPACES == set(_JXL_COLOR) | {ColorSpace.SAME_AS_SOURCE.value}
+ assert export_blocked(ExportFormat.JXL, ColorSpace.PROPHOTO.value)
+ assert not export_blocked(ExportFormat.JXL, ColorSpace.SRGB.value)
+ assert not export_blocked(ExportFormat.JXL, ColorSpace.SAME_AS_SOURCE.value)
+ assert not export_blocked(ExportFormat.TIFF, ColorSpace.PROPHOTO.value)
diff --git a/tests/test_export_service.py b/tests/test_export_service.py
index 3abc3a9d..8574777a 100644
--- a/tests/test_export_service.py
+++ b/tests/test_export_service.py
@@ -1,7 +1,7 @@
import inspect
import numpy as np
-from negpy.domain.models import ExportConfig, WorkspaceConfig
+from negpy.domain.models import ExportConfig, ExportResolutionMode, WorkspaceConfig
from negpy.desktop.workers import export as export_worker_mod
from negpy.services.rendering.image_processor import ImageProcessor
@@ -21,7 +21,7 @@ def test_apply_scaling_f32() -> None:
# Export config for 50px result (approx)
# 1 inch @ 50 DPI
- export_settings = ExportConfig(export_print_size=2.54, export_dpi=50)
+ export_settings = ExportConfig(export_resolution_mode=ExportResolutionMode.PRINT.value, export_print_size=2.54, export_dpi=50)
res = service._apply_scaling_and_border_f32(img, params, export_settings)
assert res.shape == (50, 50, 3)
@@ -36,6 +36,7 @@ def test_apply_border_f32() -> None:
from negpy.services.export.print import PrintService
export_settings = ExportConfig(
+ export_resolution_mode=ExportResolutionMode.PRINT.value,
export_print_size=2.54,
export_dpi=100,
)
diff --git a/tests/test_file_browser_widget.py b/tests/test_file_browser_widget.py
index ce7191bc..d45a17dd 100644
--- a/tests/test_file_browser_widget.py
+++ b/tests/test_file_browser_widget.py
@@ -109,8 +109,8 @@ def test_context_menu_single_selection_items(browser, session):
session.state.selected_indices = [0]
session.state.selected_file_idx = 0
labels = _action_labels(browser._build_context_menu())
- assert "Export" in labels
- assert "Export Selected" not in labels
+ assert "Export current frame" in labels
+ assert "Export selected frames" not in labels
assert "Reset Settings" in labels
assert "Unload" in labels
assert "Apply settings…" in labels
@@ -120,8 +120,8 @@ def test_context_menu_multi_selection_uses_export_selected(browser, session):
session.state.selected_indices = [0, 1]
session.state.selected_file_idx = 0
labels = _action_labels(browser._build_context_menu())
- assert "Export Selected" in labels
- assert "Export" not in labels
+ assert "Export selected frames" in labels
+ assert "Export current frame" not in labels
def test_context_menu_multi_selection_adds_apply_and_remove_selected(browser, session):