diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b8a2e125a..47f8a7169 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,9 @@ - New: **Compare** with any history step or work print. @seanharding - New: **Presets** hold Optics, Flat Field and the crop shape. @kurtwindisch - New: **Film edge print** — real markings for the bundled stocks and KEYKODE for Kodak cine stocks. +- New: **Toolbar Export** — an Export button on the canvas toolbar that exports the Frame, Selected or Roll choice of the Export panel; the Print menu takes the Tools button's look, and Export has one icon everywhere. +- New: **Tab header helpers** — Auto beside Pick Better on Exposure, Auto Crop and Square to Film on Geometry, Optical and IR Removal on Finish. +- New: **Contact sheet** — Name order by default, and a Label text field for the roll label. - Change: **Cast Removal** is a per-frame Filtration control. - Change: **IR Removal** — NegPy is the only method, improved and much faster; the OpenICE method is removed. - Fix: **Flat export** keeps the print stock's dye colors and the capture date. @thetalkingdrum @MbappeWU diff --git a/docs/FILTERING.md b/docs/FILTERING.md index f3ddffc77..166253280 100644 --- a/docs/FILTERING.md +++ b/docs/FILTERING.md @@ -1,6 +1,6 @@ # Filtering the Film Strip -The search box on the toolbar's Roll row filters the Film Strip by filename, or by terms such as `film:portra iso:>=400` (see the [User Guide](USER_GUIDE.md), §2). Sorting, hot folder mode and "Export All" use only the visible (filtered) set. +The search box on the toolbar's Roll row filters the Film Strip by filename, or by terms such as `film:portra iso:>=400` (see the [User Guide](USER_GUIDE.md), §2). Sorting, hot folder mode and "Export Roll" use only the visible (filtered) set. ## Plain mode (default) @@ -29,7 +29,7 @@ An invalid regex (for example an unclosed `[`) turns the input border red and ke ## Behavior - NegPy drops hidden files from the multi-select set. The active file moves to the first visible selection, or clears if nothing matches. An action on the selection, such as Apply or Paste Settings, never changes a hidden file. -- Export All writes only the visible files. +- Export Roll writes only the visible files. - Hot Folder mode still ingests new files. A new file that does not match the filter stays hidden until you clear or relax the filter. - The filter applies after the sort, so the visible order follows your Name, Date or Scene and Asc/Desc choice. - The filter lasts for one session. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b57a974b6..517ef3070 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -26,7 +26,7 @@ For the math behind each step, read [PIPELINE.md](PIPELINE.md). * **Canvas** (center): the picture. Scroll or pinch to zoom, drag to pan, and double-click to switch between fit and 100%. Right-click for view options, copy and paste of settings, resets and **Unload**. * **Film Strip** (under the canvas): the open frames in one row. Drag its top edge to resize it; `Ctrl+[` folds it away. -* **Toolbar** (bottom): **Photo** holds navigation, zoom, rotate, **Tools** (crop, pickers, masks, retouching), undo, the **Print** menu (Before / After and the peeks) and **Overlays**. **Roll** holds the Film Strip's import, filter, sort and roll actions. The **⋯** menu holds every action, **Preferences…** included. +* **Toolbar** (bottom): **Photo** holds navigation, zoom, rotate, **Tools** (crop, pickers, masks, retouching), undo, the **Print** menu (Before / After and the peeks), **Export** and **Overlays**. **Export** does what the Export panel's button does, and its chevron picks the frames. **Roll** holds the Film Strip's import, filter, sort and roll actions. The **⋯** menu holds every action, **Preferences…** included. * **Controls** (right): the tabs **Library**, **Roll**, **Frame**, **Metadata**, **Export** and **Scan**. Only Roll and Frame change the picture. Drag the panel by its top edge to float it. * **Frame Info** (`I`): the file name, film and size in the corners of the canvas. diff --git a/docs/media/0660.webp b/docs/media/0660.webp index c42aa2112..d6f6b52fb 100644 Binary files a/docs/media/0660.webp and b/docs/media/0660.webp differ diff --git a/negpy/desktop/view/canvas/toolbar.py b/negpy/desktop/view/canvas/toolbar.py index 1a4836886..058c8d337 100644 --- a/negpy/desktop/view/canvas/toolbar.py +++ b/negpy/desktop/view/canvas/toolbar.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import qtawesome as qta -from PyQt6.QtCore import QEvent, QPoint, QRect, QRectF, QSize, Qt +from PyQt6.QtCore import QEvent, QObject, QPoint, QRect, QRectF, QSize, Qt from PyQt6.QtGui import QActionGroup, QColor, QCursor, QIcon, QPainter, QPen from PyQt6.QtWidgets import ( QAbstractButton, @@ -45,6 +45,7 @@ wrap_tooltip, ) from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.split_button import make_split_button from negpy.desktop.view.widgets.step_combo import StepCombo from negpy.features.exposure.papers import resolve_paper from negpy.kernel.system.text import count_of @@ -93,6 +94,7 @@ class ToolbarItem: ToolbarItem("palette", tr("App"), tr("Find Control or Action…")), ToolbarItem("preferences", tr("App"), tr("Preferences…")), ToolbarItem("shortcuts", tr("App"), tr("Keyboard Shortcuts…")), + ToolbarItem("export", tr("Export"), tr("Export")), ) TOOLBAR_ITEM_BY_ID = {item.id: item for item in TOOLBAR_ITEMS} @@ -110,6 +112,7 @@ class ToolbarItem: "undo", "redo", "source", + "export", "overlays", ) @@ -130,7 +133,7 @@ class ToolbarItem: _TOOLBAR_SETTING_KEY = "toolbar_items" # A new item reaches a stored row once, before its anchor (else last); the record keeps a removed one out. -_ADDED_IDS = {"tools": "undo"} +_ADDED_IDS = {"tools": "undo", "export": "overlays"} _ADDED_SETTING_KEY = "toolbar_added_items" # A narrow bar hides these first to last; items not listed (menu actions put on the row) go @@ -145,6 +148,7 @@ class ToolbarItem: "redo", "undo", "tools", + "export", "source", "zoom", "position", @@ -429,13 +433,35 @@ def _init_ui(self) -> None: self.btn_zoom.setMinimumWidth(self._zoom_min_width) # Every source here takes the whole canvas, and the controller keeps them exclusive. - self.source_choice = StepCombo.of( - [label for _flag, _icon, label, _key, _tip in _VIEW_SOURCES], - tr("What the canvas shows: the print, a before/after split, or a peek."), + self.source_choice = MenuButton(tr("What the canvas shows: the print, a before/after split, or a peek.")) + source_group = QActionGroup(self) + self._source_actions = [] + for i, (_flag, icon, label, key, tip) in enumerate(_VIEW_SOURCES): + action = self.source_choice.choice_menu.addAction(_icon(icon), label) + action.setCheckable(True) + source_group.addAction(action) + if key: + self._menu_tip(action, tip, key) + else: + action.setToolTip(tip) + action.triggered.connect(lambda _checked=False, i=i: self._on_source_chosen(i)) + self._source_actions.append(action) + # Sized for the longest view, so changing it does not move the row. + source_width = 0 + for _flag, _icon_name, label, _key, _tip in _VIEW_SOURCES: + self.source_choice.setText(icon_text(label)) + source_width = max(source_width, self.source_choice.sizeHint().width()) + self.source_choice.setMinimumWidth(source_width) + self._show_source(0) + + # Mirrors the Export panel's button once MainWindow calls mirror_export. + self._export_menu = QMenu(self) + self._export_menu.setToolTipsVisible(True) + self.export_split, self.btn_export, self.btn_export_menu = make_split_button( + tr("Export"), "mdi6.export-variant", self._export_menu, primary=True ) - for i, (_flag, icon, _label, _key, _tip) in enumerate(_VIEW_SOURCES): - self.source_choice.setItemIcon(i, _icon(icon)) - self._sync_source_tips() + self.export_split.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + self.btn_export.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.overlays_menu = ToggleMenuButton("fa5s.layer-group", "", tr("Overlays: marks drawn over the frame that never change the edit")) self._overlay_actions = {} @@ -774,7 +800,7 @@ def _init_ui(self) -> None: btn.setFixedHeight(btn_height) btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setToolTip(wrap_tooltip(btn.toolTip())) - for btn in (self.btn_zoom, self.btn_tools, self.source_choice, self.overlays_menu): + for btn in (self.btn_zoom, self.btn_tools, self.source_choice, self.overlays_menu, self.btn_export, self.btn_export_menu): btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setFixedHeight(btn_height) btn.setIconSize(icon_size) @@ -805,6 +831,7 @@ def _init_ui(self) -> None: "palette": self.btn_palette, "preferences": self.btn_preferences, "shortcuts": self.btn_shortcuts, + "export": self.export_split, } self._row_sequence: list[tuple[QWidget, bool]] = [] self._available_width = 0 @@ -1007,7 +1034,7 @@ def _build_light_table_row(self, v_layout: QVBoxLayout, icon_size: QSize, rotate self.btn_lt_auto_crop = labeled_action("fa5s.magic", tr("Auto Crop"), "") self._tip(self.btn_lt_auto_crop, tr("Auto Crop the selected frames; crops you drew stay"), None) self.btn_lt_auto_crop.clicked.connect(lambda: self.controller.request_batch_auto_crop(assets=self._selected_frames())) - self.btn_lt_export = labeled_action("fa5s.file-export", tr("Export"), "") + self.btn_lt_export = labeled_action("mdi6.export-variant", tr("Export"), "") self._tip(self.btn_lt_export, tr("Export the selected frames with the Export tab's settings, skipping rejected frames"), None) self.btn_lt_export.clicked.connect(self.controller.request_export_selected) self.btn_lt_more = self._action_button( @@ -1284,16 +1311,40 @@ def _sync_view_state(self, *_args) -> None: """Mirror the canvas source and the overlays from state, which the controller sets before it signals.""" state = self.session.state active = next((i for i, (flag, *_rest) in enumerate(_VIEW_SOURCES) if flag and getattr(state, flag)), 0) - self.source_choice.blockSignals(True) - self.source_choice.setCurrentIndex(active) - self.source_choice.blockSignals(False) - self.source_choice.set_item_enabled(_SOURCE_INDEX["flatfield_peek"], bool(state.config.flatfield.profile_id)) + self._show_source(active) + self._source_actions[_SOURCE_INDEX["flatfield_peek"]].setEnabled(bool(state.config.flatfield.profile_id)) for flag, action in self._overlay_actions.items(): action.setChecked(bool(getattr(state, flag))) for flag, action in self._ov_view_actions.items(): action.setChecked(bool(getattr(state, flag))) self._info_actions[state.canvas_info_level].setChecked(True) + def _show_source(self, index: int) -> None: + _flag, icon, label, _key, _tip = _VIEW_SOURCES[index] + self._source_actions[index].setChecked(True) + self.source_choice.setIcon(_icon(icon)) + self.source_choice.setText(icon_text(label)) + + def mirror_export(self, source: QPushButton, scope_menu: QMenu) -> None: + """The bar's Export clicks the Export panel's button and opens its scope menu, so both share one scope.""" + self.btn_export.clicked.connect(source.click) + self._export_menu.addActions(scope_menu.actions()) + # The menu lists the current, selected and all-visible scopes in that order. + labels = (tr("Frame"), tr("Selected"), tr("Roll")) + width = 0 + for label in labels: + self._show_export_scope(label) + width = max(width, self.btn_export.sizeHint().width()) + self.btn_export.setMinimumWidth(width) + for action, label in zip(scope_menu.actions(), labels, strict=True): + action.toggled.connect(lambda checked, label=label: checked and self._show_export_scope(label)) + if action.isChecked(): + self._show_export_scope(label) + _ExportMirror(source, self.btn_export, self.btn_export_menu) + + def _show_export_scope(self, label: str) -> None: + self.btn_export.setText(icon_text(label)) + def _on_source_chosen(self, index: int) -> None: state = self.session.state flag = _VIEW_SOURCES[index][0] @@ -1342,7 +1393,6 @@ def _connect_signals(self) -> None: "printing_notes": self._ov_notes_action, "cursor_probe": self._ov_probe_action, } - self.source_choice.currentIndexChanged.connect(self._on_source_chosen) for signal in ( controller.compare_changed, controller.flat_peek_changed, @@ -1624,13 +1674,7 @@ def _menu_tip(self, action, text: str, action_id: str) -> None: self._label(action, action.text(), action_id) action.setToolTip(text) - def _sync_source_tips(self) -> None: - """Each view's tooltip in the list carries its key chip.""" - for i, (_flag, _icon, _label, key, tip) in enumerate(_VIEW_SOURCES): - self.source_choice.set_item_tooltip(i, wrap_tooltip(tooltip_with_shortcut(tip, key or None))) - def apply_shortcut_tooltips(self) -> None: - self._sync_source_tips() for target, text, ids in self._shortcut_tips: target.setToolTip(wrap_tooltip(tooltip_with_shortcut(text, ids))) for action, text, action_id in self._shortcut_labels: @@ -1731,7 +1775,7 @@ def eventFilter(self, obj, event) -> bool: # noqa: N802 def _track_rects(self) -> list[QRect]: """Each run of buttons in bar coordinates, with a label between two of them (the frame count between Previous and Next). A separator, a field, a slider or a primary button - ends a run; a segmented control carries its own track.""" + ends a run; a segmented control carries its own track, and Export takes a track of its own.""" rects: list[QRect] = [] def walk(layout) -> None: @@ -1762,6 +1806,9 @@ def flush() -> None: continue elif isinstance(widget, (QAbstractButton, QLabel)) and not widget.property("primary"): run.append(widget) + elif widget is self.export_split: + flush() + rects.append(QRect(widget.mapTo(self, QPoint(0, 0)), widget.size())) else: flush() flush() @@ -1850,3 +1897,23 @@ def fold(widgets: list[QWidget]): self._sync_separators() self._activate_layout() + + +class _ExportMirror(QObject): + """Copies the Export panel button's tooltip and enabled state to the bar's split button.""" + + def __init__(self, source: QPushButton, main: QPushButton, menu_btn: QToolButton): + super().__init__(main) + self._source, self._main, self._menu_btn = source, main, menu_btn + source.installEventFilter(self) + self._sync() + + def eventFilter(self, obj, event) -> bool: # noqa: N802 + if event.type() in (QEvent.Type.EnabledChange, QEvent.Type.ToolTipChange): + self._sync() + return False + + def _sync(self) -> None: + self._main.setEnabled(self._source.isEnabled()) + self._menu_btn.setEnabled(self._source.isEnabled()) + self._main.setToolTip(self._source.toolTip()) diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index e8199d247..a2c6962d0 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -356,6 +356,8 @@ def _init_ui(self) -> None: geo.rot_r_btn.clicked.connect(lambda: self.toolbar.rotate(-1)) geo.flip_h_btn.clicked.connect(lambda: self.toolbar.flip("horizontal")) geo.flip_v_btn.clicked.connect(lambda: self.toolbar.flip("vertical")) + export = self.right_panel.export_sidebar + self.toolbar.mirror_export(export.export_main_btn, export._export_menu) # Object names are required for saveState/restoreState to identify the docks. self.drawer.setObjectName("controls_dock") diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py index 9ec2b5825..e225cf93a 100644 --- a/negpy/desktop/view/sidebar/export.py +++ b/negpy/desktop/view/sidebar/export.py @@ -1014,18 +1014,18 @@ def _add_bake_row(self) -> QHBoxLayout: # itself triggers an export. key -> (menu label, button label, tooltip) _EXPORT_SCOPES = { "current": ( - tr("Export Current Frame"), - tr("Export Current Frame"), + tr("Frame"), + tr("Export Frame"), tr("Export the current frame with the settings below"), ), "selected": ( - tr("Export Selected Frames"), + tr("Selected"), tr("Export Selected"), tr("Export the selected filmstrip frames using the settings below"), ), "all": ( - tr("Export All Visible Frames"), - tr("Export All"), + tr("Roll"), + tr("Export Roll"), tr("Export every visible frame using the settings below"), ), } @@ -1039,18 +1039,18 @@ def _add_bake_row(self) -> QHBoxLayout: _PRESET_SCOPES = { "current": ( - tr("Export Current Frame"), + tr("Frame"), tr("Export Presets"), tr("Export the current frame with every enabled preset"), ), "selected": ( - tr("Export Selected Frames"), + tr("Selected"), tr("Export Presets (Selected)"), tr("Export the selected filmstrip frames with every enabled preset"), ), "all": ( - tr("Export All Visible Frames"), - tr("Export Presets (All)"), + tr("Roll"), + tr("Export Presets (Roll)"), tr("Export every visible frame with every enabled preset"), ), } @@ -1075,7 +1075,7 @@ def _add_export_section(self) -> None: 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(tr("Export"), "fa5s.check-circle", menu, primary=True) + container, self.export_main_btn, self.export_menu_btn = make_split_button(tr("Export"), "mdi6.export-variant", menu, primary=True) container.setContentsMargins(THEME.space_xl, 0, THEME.space_xl, 0) self.export_menu_btn.setToolTip(tr("Choose what the Export button does")) self.layout.addWidget(container) diff --git a/negpy/desktop/view/styles/templates.py b/negpy/desktop/view/styles/templates.py index 2bac37450..b78af1362 100644 --- a/negpy/desktop/view/styles/templates.py +++ b/negpy/desktop/view/styles/templates.py @@ -488,8 +488,9 @@ def pane_header_qss() -> str: def dialog_pane_qss() -> str: - """Left column pane in two-pane dialogs: panel fill + right divider.""" - return f"background: {THEME.bg_panel}; border-right: 1px solid {THEME.border_primary};" + """Left column pane in two-pane dialogs: panel fill + right divider. Scoped to the pane, so the + pane takes the objectName "dialog_pane" and its controls keep their own look.""" + return f"QWidget#dialog_pane {{background: {THEME.bg_panel}; border-right: 1px solid {THEME.border_primary};}}" def labeled_toggle_qss() -> str: diff --git a/negpy/desktop/view/widgets/crosstalk_editor_dialog.py b/negpy/desktop/view/widgets/crosstalk_editor_dialog.py index e757db330..81fc5af3f 100644 --- a/negpy/desktop/view/widgets/crosstalk_editor_dialog.py +++ b/negpy/desktop/view/widgets/crosstalk_editor_dialog.py @@ -120,6 +120,7 @@ def _init_ui(self) -> None: # Left: profile list + new / copy / delete left = QWidget() left.setMinimumWidth(180) + left.setObjectName("dialog_pane") left.setStyleSheet(dialog_pane_qss()) left_layout = QVBoxLayout(left) left_layout.setContentsMargins(THEME.space_lg, THEME.space_lg, THEME.space_lg, THEME.space_lg) diff --git a/negpy/desktop/view/widgets/export_presets_dialog.py b/negpy/desktop/view/widgets/export_presets_dialog.py index 56ac877a2..9ec37b71f 100644 --- a/negpy/desktop/view/widgets/export_presets_dialog.py +++ b/negpy/desktop/view/widgets/export_presets_dialog.py @@ -66,6 +66,7 @@ def _init_ui(self) -> None: # Left: preset list + action buttons left = QWidget() left.setFixedWidth(220) + left.setObjectName("dialog_pane") left.setStyleSheet(dialog_pane_qss()) left_layout = QVBoxLayout(left) left_layout.setContentsMargins(THEME.space_lg, THEME.space_lg, THEME.space_lg, THEME.space_lg) diff --git a/negpy/desktop/view/widgets/favourites_dialog.py b/negpy/desktop/view/widgets/favourites_dialog.py index 6c5fcd8d9..9710585f2 100644 --- a/negpy/desktop/view/widgets/favourites_dialog.py +++ b/negpy/desktop/view/widgets/favourites_dialog.py @@ -89,6 +89,7 @@ def __init__( def _build_available(self) -> QWidget: pane = QWidget() + pane.setObjectName("dialog_pane") pane.setStyleSheet(dialog_pane_qss()) layout = QVBoxLayout(pane) layout.setContentsMargins(0, 0, THEME.space_xl, 0) diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index b57a974b6..517ef3070 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -26,7 +26,7 @@ For the math behind each step, read [PIPELINE.md](PIPELINE.md). * **Canvas** (center): the picture. Scroll or pinch to zoom, drag to pan, and double-click to switch between fit and 100%. Right-click for view options, copy and paste of settings, resets and **Unload**. * **Film Strip** (under the canvas): the open frames in one row. Drag its top edge to resize it; `Ctrl+[` folds it away. -* **Toolbar** (bottom): **Photo** holds navigation, zoom, rotate, **Tools** (crop, pickers, masks, retouching), undo, the **Print** menu (Before / After and the peeks) and **Overlays**. **Roll** holds the Film Strip's import, filter, sort and roll actions. The **⋯** menu holds every action, **Preferences…** included. +* **Toolbar** (bottom): **Photo** holds navigation, zoom, rotate, **Tools** (crop, pickers, masks, retouching), undo, the **Print** menu (Before / After and the peeks), **Export** and **Overlays**. **Export** does what the Export panel's button does, and its chevron picks the frames. **Roll** holds the Film Strip's import, filter, sort and roll actions. The **⋯** menu holds every action, **Preferences…** included. * **Controls** (right): the tabs **Library**, **Roll**, **Frame**, **Metadata**, **Export** and **Scan**. Only Roll and Frame change the picture. Drag the panel by its top edge to float it. * **Frame Info** (`I`): the file name, film and size in the corners of the canvas. diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 4dcab7ded..ef2bed484 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -848,15 +848,15 @@ "Expand or collapse this tab's cards": "このタブのカードを展開または折りたたむ", "Expansion": "拡張", "Export": "書き出し", - "Export All": "すべてエクスポート", - "Export All Visible Frames": "表示されているすべてのフレームをエクスポート", "Export Current Frame": "現在のフレームをエクスポート", + "Export Frame": "フレームを書き出し", "Export Linear Output": "リニア出力のエクスポート", "Export Notes": "ノートを書き出し", "Export Presets": "プリセットのエクスポート", - "Export Presets (All)": "プリセットをエクスポート (すべて)", + "Export Presets (Roll)": "プリセットをエクスポート (ロール)", "Export Presets (Selected)": "プリセットをエクスポート (選択済み)", "Export Profile": "プロファイルのエクスポート", + "Export Roll": "ロールを書き出し", "Export Selected": "選択したものをエクスポート", "Export Selected Frames": "選択したフレームをエクスポートする", "Export Sidecars": "サイドカーのエクスポート", @@ -2240,6 +2240,7 @@ "Select the {band} bare-light exposure": "{band} の光源のみの露光を選択してください", "Select two or more exposures of the same frame to merge": "同じフレームの 2 つ以上の露出を選択して結合します", "Select two or more frames to stitch": "ステッチするフレームを 2 つ以上選択してください", + "Selected": "選択", "Selected Frames ({sel_count})": "選択されたフレーム ({sel_count})", "Selenium": "セレン", "Selenium down": "セレンダウン", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index d8ce78a44..95021b662 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -848,15 +848,15 @@ "Expand or collapse this tab's cards": "이 탭의 카드를 펼치거나 접습니다.", "Expansion": "확장", "Export": "내보내기", - "Export All": "모두 내보내기", - "Export All Visible Frames": "보이는 모든 프레임 내보내기", "Export Current Frame": "현재 프레임 내보내기", + "Export Frame": "프레임 내보내기", "Export Linear Output": "선형 출력 내보내기", "Export Notes": "노트 내보내기", "Export Presets": "내보내기 사전 설정", - "Export Presets (All)": "사전 설정으로 내보내기(모두)", + "Export Presets (Roll)": "사전 설정으로 내보내기(롤)", "Export Presets (Selected)": "사전 설정으로 내보내기(선택됨)", "Export Profile": "내보내기 프로필", + "Export Roll": "롤 내보내기", "Export Selected": "선택 내보내기", "Export Selected Frames": "선택한 프레임 내보내기", "Export Sidecars": "사이드카 내보내기", @@ -2240,6 +2240,7 @@ "Select the {band} bare-light exposure": "광원만 찍은 {band} 노출을 선택하세요", "Select two or more exposures of the same frame to merge": "병합할 동일한 프레임의 노출을 두 개 이상 선택하세요.", "Select two or more frames to stitch": "스티치할 프레임을 두 개 이상 선택하세요.", + "Selected": "선택", "Selected Frames ({sel_count})": "선택한 프레임({sel_count}개)", "Selenium": "셀레늄", "Selenium down": "셀레늄 다운", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index 17b69b66f..c55d6243f 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -848,15 +848,15 @@ "Expand or collapse this tab's cards": "展开或折叠此选项卡的卡片", "Expansion": "扩展", "Export": "导出", - "Export All": "导出全部", - "Export All Visible Frames": "导出所有可见画面", "Export Current Frame": "导出当前画面", + "Export Frame": "导出画面", "Export Linear Output": "导出线性输出", "Export Notes": "导出笔记", "Export Presets": "导出预设", - "Export Presets (All)": "导出预设(全部)", + "Export Presets (Roll)": "导出预设(胶卷)", "Export Presets (Selected)": "导出预设(所选)", "Export Profile": "导出配置", + "Export Roll": "导出胶卷", "Export Selected": "导出所选", "Export Selected Frames": "导出选定画面", "Export Sidecars": "导出附属文件", @@ -2240,6 +2240,7 @@ "Select the {band} bare-light exposure": "选择{band}裸光源曝光", "Select two or more exposures of the same frame to merge": "请选择同一画面的至少两次曝光进行合并", "Select two or more frames to stitch": "请选择至少两帧进行拼接", + "Selected": "所选", "Selected Frames ({sel_count})": "选定画面({sel_count})", "Selenium": "硒", "Selenium down": "减小硒调色", diff --git a/tests/test_canvas_toolbar.py b/tests/test_canvas_toolbar.py index 4362bf58c..90663a830 100644 --- a/tests/test_canvas_toolbar.py +++ b/tests/test_canvas_toolbar.py @@ -2,7 +2,7 @@ import unittest from unittest.mock import MagicMock -from PyQt6.QtWidgets import QApplication, QDialog +from PyQt6.QtWidgets import QApplication, QDialog, QMenu, QPushButton from negpy.desktop.view.canvas.toolbar import DEFAULT_TOOLBAR_IDS, TOOLBAR_ITEM_BY_ID, TOOLBAR_ITEMS, ActionToolbar, load_toolbar_items from negpy.desktop.session import ToolMode @@ -53,7 +53,7 @@ def _make_toolbar() -> ActionToolbar: return ActionToolbar(controller) -def _repo(stored, added=("tools",)) -> MagicMock: +def _repo(stored, added=("tools", "export")) -> MagicMock: """A stored row whose new items were already offered, unless *added* says otherwise.""" repo = MagicMock() settings = {"toolbar_items": stored, "toolbar_added_items": list(added)} @@ -349,14 +349,14 @@ def test_a_removed_frame_counter_stays_removed(self): repo.save_global_setting.assert_not_called() def test_a_stored_row_gets_tools_once_before_undo(self): - repo = _repo(["prev", "undo", "redo"], added=()) + repo = _repo(["prev", "undo", "redo"], added=("export",)) self.assertEqual(load_toolbar_items(repo), ["prev", "tools", "undo", "redo"]) repo.save_global_settings.assert_called_once_with( - {"toolbar_items": ["prev", "tools", "undo", "redo"], "toolbar_added_items": ["tools"]} + {"toolbar_items": ["prev", "tools", "undo", "redo"], "toolbar_added_items": ["export", "tools"]} ) def test_a_stored_row_without_undo_gets_tools_last(self): - self.assertEqual(load_toolbar_items(_repo(["prev", "next"], added=())), ["prev", "next", "tools"]) + self.assertEqual(load_toolbar_items(_repo(["prev", "next"], added=())), ["prev", "next", "tools", "export"]) def test_a_removed_tools_button_stays_removed(self): repo = _repo(["prev", "undo"]) @@ -629,8 +629,7 @@ def test_shows_the_place_in_the_strip(self): class TestViewChooser(unittest.TestCase): def _choose(self, tb: ActionToolbar, label: str) -> None: - index = next(i for i in range(tb.source_choice.count()) if tb.source_choice.itemText(i).startswith(label)) - tb.source_choice.setCurrentIndex(index) + next(a for a in tb._source_actions if a.text().startswith(label)).trigger() def test_a_peek_drives_the_controller(self): tb = _make_toolbar() @@ -659,25 +658,70 @@ def test_the_label_follows_a_peek_started_by_its_key(self): tb = _make_toolbar() tb.session.state.embedded_peek = True tb._sync_view_state() - self.assertEqual(tb.source_choice.currentText(), "Embedded") + self.assertEqual(tb.source_choice.text().strip(), "Embedded") tb.controller.toggle_embedded_peek.assert_not_called() def test_a_refused_peek_falls_back_to_print(self): """A file with no embedded preview leaves the state off; the chooser must not claim it is on.""" tb = _make_toolbar() self._choose(tb, "Embedded") - self.assertEqual(tb.source_choice.currentText(), "Print") + self.assertEqual(tb.source_choice.text().strip(), "Print") def test_flat_scan_needs_no_flat_output(self): """The peek renders the flat master from any frame, like its key.""" tb = _make_toolbar() tb.session.state.flat_output = False tb._sync_view_state() - flat = next(i for i in range(tb.source_choice.count()) if tb.source_choice.itemText(i).startswith("Flat Scan")) - self.assertTrue(tb.source_choice.is_item_enabled(flat)) + flat = next(a for a in tb._source_actions if a.text().startswith("Flat Scan")) + self.assertTrue(flat.isEnabled()) self.assertTrue(tb._ov_flat_peek_action.isEnabled()) +class TestExportMirror(unittest.TestCase): + def _mirrored(self): + tb = _make_toolbar() + source = QPushButton() + source.setToolTip("Export the current frame") + menu = QMenu() + for text in ("Frame", "Selected", "Roll"): + menu.addAction(text).setCheckable(True) + menu.actions()[0].setChecked(True) + tb.mirror_export(source, menu) + return tb, source, menu + + def test_clicks_the_panel_button_and_shares_its_scope_menu(self): + tb, source, menu = self._mirrored() + clicked = [] + source.clicked.connect(lambda: clicked.append(True)) + tb.btn_export.click() + self.assertEqual(clicked, [True]) + self.assertEqual(tb._export_menu.actions(), menu.actions()) + + def test_the_label_names_the_chosen_scope(self): + tb, _source, menu = self._mirrored() + self.assertEqual(tb.btn_export.text().strip(), "Frame") + menu.actions()[2].setChecked(True) + self.assertEqual(tb.btn_export.text().strip(), "Roll") + menu.actions()[1].setChecked(True) + self.assertEqual(tb.btn_export.text().strip(), "Selected") + + def test_follows_the_panel_buttons_state(self): + tb, source, _menu = self._mirrored() + source.setEnabled(False) + source.setToolTip("Export every visible frame") + self.assertFalse(tb.btn_export.isEnabled()) + self.assertFalse(tb.btn_export_menu.isEnabled()) + self.assertEqual(tb.btn_export.toolTip(), "Export every visible frame") + + def test_a_stored_row_gets_export_once_before_overlays(self): + repo = _repo(["source", "overlays"], added=("tools",)) + self.assertEqual(load_toolbar_items(repo), ["source", "export", "overlays"]) + + def test_on_the_stock_row_and_hideable(self): + self.assertIn("export", DEFAULT_TOOLBAR_IDS) + self.assertIn("export", TOOLBAR_ITEM_BY_ID) + + class TestOverlaysMenu(unittest.TestCase): def test_an_overlay_drives_the_controller(self): tb = _make_toolbar() @@ -1088,7 +1132,7 @@ def test_each_run_of_buttons_sits_flat_in_one_track(): def rect(w): return QRect(w.mapTo(tb, QPoint(0, 0)), w.size()) - buttons = [b for b in tb._view_row.findChildren(QAbstractButton) if b.isVisible()] + buttons = [b for b in tb._view_row.findChildren(QAbstractButton) if b.isVisible() and not b.property("primary")] assert buttons and all(any(t.contains(rect(b)) for t in tracks) for b in buttons) assert not any(t.intersects(rect(tb.mode_switch)) for t in tracks) assert tb.btn_tools.isFlat() and not tb.btn_tool_done.isFlat()