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):