diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index ec44746a..47d93337 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -11,7 +11,7 @@ This guide is for new users. It explains what each control does, when you'd reac ### Screen layout * **Left, the film strip**: your loaded frames as a contact sheet, plus import, sorting, and triage tools. -* **Centre, the canvas**: the live preview of the current frame. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. +* **Centre, the canvas**: the live preview of the current frame. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. With nothing loaded it shows **Load some scans to get started** — click it for **Add files** / **Add folder**. * **Right, the controls**: a pinned **Analysis** readout at the top, and below it an icon tab bar. Each icon opens a *workflow page* holding one or more collapsible panels. ### The workflow (and the order things happen) @@ -41,7 +41,7 @@ Both side panels can be narrowed to give the canvas more room. As the controls p ## 2. Film strip (left panel) -The header shows the NegPy logo and version (and an update link when a new release is out). Below it: the toolbar, the search box, and then two collapsible sections — **Library** (the folders your scans live in) and **Film Strip** (the frames you have open). Click either heading to fold it away; the one still open takes the whole panel, and a folded one keeps just its heading. NegPy remembers which were open. +The header shows the NegPy logo and version (and an update link when a new release is out); the chevron at its top-right folds the branding away to give the frames more room, and NegPy remembers that too. Below it: the toolbar, the search box, and then two collapsible sections — **Library** (the folders your scans live in) and **Film Strip** (the frames you have open). Click either heading to fold it away; the one still open takes the whole panel, and a folded one keeps just its heading. NegPy remembers which were open. ### Your library diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index 10ba16e5..0d83baee 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -1,14 +1,16 @@ import os -from typing import Callable, Optional +from typing import Optional import numpy as np from PIL import Image -from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtCore import Qt, QEvent, QTimer, pyqtSignal from PyQt6.QtWidgets import ( QApplication, QDialog, QMainWindow, + QMenu, QMessageBox, + QPushButton, QStatusBar, QVBoxLayout, QWidget, @@ -89,31 +91,57 @@ def _display_buffer_for_canvas(buffer: object) -> object: class _EmptyStateOverlay(QWidget): - """Shown on top of the canvas when no image is loaded.""" + """Shown on top of the canvas when no image is loaded. - def __init__(self, parent: QWidget, on_tour: "Callable[[], None]") -> None: + Tracks the canvas rather than the window: hiding a dock resizes the canvas + without resizing the window, which used to leave this stranded off-centre + while the floating toolbar (which the canvas lays out) moved with it. + """ + + add_files_requested = pyqtSignal() + add_folder_requested = pyqtSignal() + tour_requested = pyqtSignal() + + def __init__(self, parent: QWidget) -> None: super().__init__(parent) + parent.installEventFilter(self) + layout = QVBoxLayout(self) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.setSpacing(12) - from PyQt6.QtWidgets import QLabel, QPushButton - - label = QLabel("Load some scans to get started") - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - label.setStyleSheet(f"color: {THEME.text_muted}; font-size: 15px;") - layout.addWidget(label) + self.load_btn = QPushButton("Load some scans to get started") + self.load_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.load_btn.setStyleSheet( + f"QPushButton {{ background: transparent; color: {THEME.text_muted}; " + f"border: none; font-size: 15px; padding: 4px 8px; }}" + f"QPushButton:hover {{ color: {THEME.text_primary}; text-decoration: underline; }}" + ) + self.load_btn.clicked.connect(self._show_load_menu) + layout.addWidget(self.load_btn, alignment=Qt.AlignmentFlag.AlignHCenter) - tour_btn = QPushButton("Take the tour") - tour_btn.setFixedWidth(140) - tour_btn.setStyleSheet( + self.tour_btn = QPushButton("Take the tour") + self.tour_btn.setFixedWidth(140) + self.tour_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.tour_btn.setStyleSheet( f"QPushButton {{ background: transparent; color: {THEME.text_muted}; " f"border: 1px solid {THEME.border_primary}; border-radius: 3px; " f"padding: 5px 14px; font-size: 12px; }}" f"QPushButton:hover {{ color: {THEME.text_primary}; }}" ) - tour_btn.clicked.connect(on_tour) - layout.addWidget(tour_btn, alignment=Qt.AlignmentFlag.AlignHCenter) + self.tour_btn.clicked.connect(self.tour_requested) + layout.addWidget(self.tour_btn, alignment=Qt.AlignmentFlag.AlignHCenter) + + def _show_load_menu(self) -> None: + menu = QMenu(self) + menu.addAction("Add files…").triggered.connect(self.add_files_requested) + menu.addAction("Add folder…").triggered.connect(self.add_folder_requested) + menu.exec(self.load_btn.mapToGlobal(self.load_btn.rect().bottomLeft())) + + def eventFilter(self, obj, event) -> bool: + if obj is self.parent() and event.type() == QEvent.Type.Resize: + self.setGeometry(self.parent().rect()) + return False def resizeEvent(self, event) -> None: super().resizeEvent(event) @@ -231,7 +259,11 @@ def _init_ui(self) -> None: self.toolbar = ActionToolbar(self.controller) self.canvas.set_floating_toolbar(self.toolbar) - self.empty_state = _EmptyStateOverlay(self.canvas, lambda: self.show_tutorial()) + self.empty_state = _EmptyStateOverlay(self.canvas) + self.empty_state.tour_requested.connect(self.show_tutorial) + # session_panel is built further down; resolve the browser lazily. + self.empty_state.add_files_requested.connect(lambda: self.session_panel.file_browser.prompt_add_files()) + self.empty_state.add_folder_requested.connect(lambda: self.session_panel.file_browser.prompt_add_folder()) self.empty_state.raise_() self.loading_overlay = LoadingOverlay(self.canvas) diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py index 9abd26c2..8596cc8d 100644 --- a/negpy/desktop/view/sidebar/files.py +++ b/negpy/desktop/view/sidebar/files.py @@ -583,8 +583,8 @@ def _rebalance_sections(self) -> None: def _connect_signals(self) -> None: self.library_btn.clicked.connect(lambda: self.library_requested.emit(True)) - self.add_files_btn.clicked.connect(self._on_add_files) - self.add_folder_btn.clicked.connect(self._on_add_folder) + self.add_files_btn.clicked.connect(self.prompt_add_files) + self.add_folder_btn.clicked.connect(self.prompt_add_folder) self.unload_btn.clicked.connect(self._on_unload_clicked) self.list_view.clicked.connect(self._on_item_clicked) self.list_view.doubleClicked.connect(self._on_item_double_clicked) @@ -877,7 +877,8 @@ def _scan_folder(self) -> None: if new_files: self.controller.request_asset_discovery(new_files) - def _on_add_files(self) -> None: + def prompt_add_files(self) -> None: + """Public entry point: also driven by the canvas empty state.""" wildcards = get_supported_raw_wildcards() start_dir = self.session.repo.get_global_setting("last_open_folder", "") or "" files, _ = QFileDialog.getOpenFileNames( @@ -890,7 +891,8 @@ def _on_add_files(self) -> None: self.session.repo.save_global_setting("last_open_folder", os.path.dirname(files[0])) self.controller.request_asset_discovery(files, auto_open=True) - def _on_add_folder(self) -> None: + def prompt_add_folder(self) -> None: + """Public entry point: also driven by the canvas empty state.""" start_dir = self.session.repo.get_global_setting("last_open_folder", "") or "" folder = QFileDialog.getExistingDirectory(self, "Select Folder", start_dir) if folder: @@ -988,8 +990,8 @@ def _build_session_menu(self) -> QMenu: """Mirrors the panel toolbar's add/clear tools, for a right click on empty space.""" icon_color = THEME.text_primary menu = QMenu(self) - menu.addAction(qta.icon("fa5s.file-import", color=icon_color), "Add files…").triggered.connect(self._on_add_files) - menu.addAction(qta.icon("fa5s.folder-plus", color=icon_color), "Add folder…").triggered.connect(self._on_add_folder) + menu.addAction(qta.icon("fa5s.file-import", color=icon_color), "Add files…").triggered.connect(self.prompt_add_files) + menu.addAction(qta.icon("fa5s.folder-plus", color=icon_color), "Add folder…").triggered.connect(self.prompt_add_folder) menu.addSeparator() clear = menu.addAction(qta.icon("fa5s.times-circle", color=icon_color), "Clear all") clear.triggered.connect(self._on_clear_all) diff --git a/negpy/desktop/view/sidebar/header.py b/negpy/desktop/view/sidebar/header.py index 13328b72..cede0aa5 100644 --- a/negpy/desktop/view/sidebar/header.py +++ b/negpy/desktop/view/sidebar/header.py @@ -1,10 +1,12 @@ +import qtawesome as qta from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QToolButton, ) -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QSize, pyqtSignal from PyQt6.QtGui import QPixmap from negpy.desktop.controller import AppController @@ -16,16 +18,42 @@ class SidebarHeader(QWidget): """ Top header for the sidebar containing the logo and version. + + The branding collapses to a single chevron row so the film strip can claim the + space; the caller persists ``expanded_changed`` (see the sidebar sections). """ - def __init__(self, controller: AppController): + expanded_changed = pyqtSignal(bool) + + def __init__(self, controller: AppController, expanded: bool = True): super().__init__() self._init_ui() + self.set_expanded(expanded) def _init_ui(self) -> None: layout = QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 0) - layout.setSpacing(5) + layout.setContentsMargins(5, 2, 5, 0) + layout.setSpacing(2) + + self.toggle_button = QToolButton() + self.toggle_button.setCheckable(True) + self.toggle_button.setChecked(True) + self.toggle_button.setAutoRaise(True) + self.toggle_button.setIconSize(QSize(10, 10)) + self.toggle_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.toggle_button.setStyleSheet("QToolButton { border: none; background: transparent; padding: 0px; }") + self.toggle_button.toggled.connect(self.set_expanded) + + toggle_row = QHBoxLayout() + toggle_row.setContentsMargins(0, 0, 0, 0) + toggle_row.addStretch() + toggle_row.addWidget(self.toggle_button) + layout.addLayout(toggle_row) + + self.body = QWidget() + body_layout = QVBoxLayout(self.body) + body_layout.setContentsMargins(0, 0, 0, 0) + body_layout.setSpacing(5) header = QHBoxLayout() header.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -47,9 +75,25 @@ def _init_ui(self) -> None: header.addWidget(icon_label) header.addWidget(name_label) - layout.addLayout(header) + body_layout.addLayout(header) self.ver_label = QLabel(f"v{get_app_version()}") self.ver_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.ver_label.setStyleSheet(f"font-size: 14px; color: {THEME.text_secondary}; font-weight: bold;") - layout.addWidget(self.ver_label) + body_layout.addWidget(self.ver_label) + + layout.addWidget(self.body) + + def is_expanded(self) -> bool: + return self.toggle_button.isChecked() + + def set_expanded(self, expanded: bool) -> None: + expanded = bool(expanded) + if self.toggle_button.isChecked() != expanded: + self.toggle_button.blockSignals(True) + self.toggle_button.setChecked(expanded) + self.toggle_button.blockSignals(False) + self.body.setVisible(expanded) + self.toggle_button.setIcon(qta.icon("fa5s.chevron-up" if expanded else "fa5s.chevron-down", color=THEME.text_muted)) + self.toggle_button.setToolTip("Hide the NegPy logo and version" if expanded else "Show the NegPy logo and version") + self.expanded_changed.emit(expanded) diff --git a/negpy/desktop/view/sidebar/session_panel.py b/negpy/desktop/view/sidebar/session_panel.py index 70eaa5f2..7a9d5b8d 100644 --- a/negpy/desktop/view/sidebar/session_panel.py +++ b/negpy/desktop/view/sidebar/session_panel.py @@ -39,7 +39,10 @@ def _init_ui(self) -> None: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - self.header = SidebarHeader(self.controller) + repo = self.controller.session.repo + persisted = repo.get_global_setting("section_expanded_app_header") + self.header = SidebarHeader(self.controller, expanded=bool(persisted) if persisted is not None else True) + self.header.expanded_changed.connect(lambda v: repo.save_global_setting("section_expanded_app_header", v)) layout.addWidget(self.header) self.update_label = QLabel("") diff --git a/tests/test_empty_state_and_header.py b/tests/test_empty_state_and_header.py new file mode 100644 index 00000000..136efd6e --- /dev/null +++ b/tests/test_empty_state_and_header.py @@ -0,0 +1,152 @@ +"""Canvas empty state and the collapsible sidebar branding header.""" + +from unittest.mock import MagicMock + +import pytest +from PyQt6.QtWidgets import QApplication, QWidget + +from negpy.desktop.session import DesktopSessionManager +from negpy.desktop.view.main_window import _EmptyStateOverlay +from negpy.desktop.view.sidebar.header import SidebarHeader +from negpy.desktop.view.sidebar.session_panel import SessionPanel +from negpy.infrastructure.storage.repository import StorageRepository + + +@pytest.fixture +def host(qapp): + """A stand-in for the canvas: the overlay must track whatever it is parented to.""" + w = QWidget() + w.resize(600, 400) + w.show() + QApplication.processEvents() + return w + + +@pytest.fixture +def overlay(host): + ov = _EmptyStateOverlay(host) + ov.show() + QApplication.processEvents() + return ov + + +# --- empty state ---------------------------------------------------------- + + +def test_prompt_is_a_button_not_a_label(overlay): + assert overlay.load_btn.text() == "Load some scans to get started" + assert overlay.load_btn.isEnabled() + + +def test_load_menu_offers_both_import_routes(overlay, monkeypatch): + captured: list[str] = [] + + class _Menu: + def __init__(self, *_a, **_k): + pass + + def addAction(self, text): + captured.append(text) + return MagicMock() + + def exec(self, *_a, **_k): + return None + + monkeypatch.setattr("negpy.desktop.view.main_window.QMenu", _Menu) + overlay._show_load_menu() + assert captured == ["Add files…", "Add folder…"] + + +def test_tour_button_emits_its_signal(overlay): + seen: list[int] = [] + overlay.tour_requested.connect(lambda: seen.append(1)) + overlay.tour_btn.click() + assert seen == [1] + + +def test_overlay_follows_the_canvas_when_it_resizes(overlay, host): + """Regression: hiding a dock resizes the canvas but not the window, so the + overlay stayed at its old width and drifted off-centre from the floating + toolbar (which the canvas lays out itself).""" + host.resize(1000, 400) + QApplication.processEvents() + assert overlay.size() == host.size() + + host.resize(480, 720) + QApplication.processEvents() + assert overlay.size() == host.size() + + +def test_overlay_stays_centred_on_its_parent(overlay, host): + host.resize(1200, 500) + QApplication.processEvents() + assert overlay.geometry().center().x() == host.rect().center().x() + + +# --- collapsible branding header ----------------------------------------- + + +@pytest.fixture +def controller(tmp_path, monkeypatch): + monkeypatch.setattr("negpy.desktop.view.sidebar.session_panel.check_for_updates", lambda: None) + repo = StorageRepository(str(tmp_path / "edits.db"), str(tmp_path / "settings.db")) + repo.initialize() + ctrl = MagicMock() + ctrl.session = DesktopSessionManager(repo) + ctrl.library_roots.return_value = [] + return ctrl + + +def test_header_expanded_shows_logo_and_version(qapp, controller): + header = SidebarHeader(controller, expanded=True) + assert header.is_expanded() + assert header.body.isVisible() or not header.isVisible() # body hidden only when collapsed + assert header.ver_label.text().startswith("v") + + +def test_collapsing_hides_the_branding_and_frees_height(qapp, controller): + header = SidebarHeader(controller, expanded=True) + header.show() + QApplication.processEvents() + tall = header.sizeHint().height() + + header.set_expanded(False) + QApplication.processEvents() + + assert not header.body.isVisible() + assert header.sizeHint().height() < tall + + +def test_toggle_button_drives_the_collapse(qapp, controller): + header = SidebarHeader(controller, expanded=True) + header.show() + QApplication.processEvents() + + header.toggle_button.click() + assert not header.is_expanded() + assert not header.body.isVisible() + + +def test_header_emits_expanded_changed(qapp, controller): + header = SidebarHeader(controller, expanded=True) + seen: list[bool] = [] + header.expanded_changed.connect(seen.append) + header.set_expanded(False) + header.set_expanded(True) + assert seen == [False, True] + + +def test_session_panel_persists_and_restores_header_state(qapp, controller): + repo = controller.session.repo + panel = SessionPanel(controller) + assert panel.header.is_expanded(), "defaults to expanded" + + panel.header.set_expanded(False) + assert repo.get_global_setting("section_expanded_app_header") is False + + reopened = SessionPanel(controller) + assert not reopened.header.is_expanded(), "collapsed state survives a restart" + + reopened.header.set_expanded(True) + assert repo.get_global_setting("section_expanded_app_header") is True + assert SessionPanel(controller).header.is_expanded() diff --git a/tests/test_file_browser_widget.py b/tests/test_file_browser_widget.py index 7ff19065..016c0822 100644 --- a/tests/test_file_browser_widget.py +++ b/tests/test_file_browser_widget.py @@ -272,7 +272,7 @@ def test_add_files_uses_and_saves_last_folder(browser, session): "negpy.desktop.view.sidebar.files.QFileDialog.getOpenFileNames", return_value=(["/photos/scans/2024/x.cr2"], ""), ) as dlg: - browser._on_add_files() + browser.prompt_add_files() assert dlg.call_args.args[2] == "/photos/scans" session.repo.save_global_setting.assert_called_with("last_open_folder", "/photos/scans/2024") @@ -283,7 +283,7 @@ def test_add_folder_uses_and_saves_parent_of_last_folder(browser, session): "negpy.desktop.view.sidebar.files.QFileDialog.getExistingDirectory", return_value="/photos/scans/2024", ) as dlg: - browser._on_add_folder() + browser.prompt_add_folder() assert dlg.call_args.args[2] == "/photos/scans" session.repo.save_global_setting.assert_called_with("last_open_folder", "/photos/scans") @@ -294,7 +294,7 @@ def test_add_files_falls_back_to_empty_dir_when_unset(browser, session): "negpy.desktop.view.sidebar.files.QFileDialog.getOpenFileNames", return_value=([], ""), ) as dlg: - browser._on_add_files() + browser.prompt_add_files() assert dlg.call_args.args[2] == "" assert not any(c.args and c.args[0] == "last_open_folder" for c in session.repo.save_global_setting.call_args_list)