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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/FILTERING.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Binary file modified docs/media/0660.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
111 changes: 89 additions & 22 deletions negpy/desktop/view/canvas/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -110,6 +112,7 @@ class ToolbarItem:
"undo",
"redo",
"source",
"export",
"overlays",
)

Expand All @@ -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
Expand All @@ -145,6 +148,7 @@ class ToolbarItem:
"redo",
"undo",
"tools",
"export",
"source",
"zoom",
"position",
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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())
2 changes: 2 additions & 0 deletions negpy/desktop/view/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
20 changes: 10 additions & 10 deletions negpy/desktop/view/sidebar/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
),
}
Expand All @@ -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"),
),
}
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions negpy/desktop/view/styles/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/widgets/crosstalk_editor_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/widgets/export_presets_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/widgets/favourites_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading