diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index a4d7f84..80137d0 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -2,17 +2,32 @@ import logging import sys +import struct from pathlib import Path from typing import Optional, List, Dict from datetime import date - import os import typer import concurrent.futures -from PySide6.QtCore import QUrl, QTimer, QObject, QEvent, Signal +import threading +import subprocess +from faststack.ui.provider import ImageProvider, UIState +from PySide6.QtGui import QDrag, QPixmap +from PySide6.QtCore import ( + QUrl, + QTimer, + QObject, + QEvent, + Signal, + Slot, + QMimeData, + Qt, + QPoint +) from PySide6.QtWidgets import QApplication, QFileDialog from PySide6.QtQml import QQmlApplicationEngine +# ⬇️ these are the ones that went missing from faststack.config import config from faststack.logging_setup import setup_logging from faststack.models import ImageFile, DecodedImage, EntryMetadata @@ -22,14 +37,31 @@ from faststack.io.helicon import launch_helicon_focus from faststack.imaging.cache import ByteLRUCache, get_decoded_image_size from faststack.imaging.prefetch import Prefetcher -from faststack.ui.provider import ImageProvider, UIState +from faststack.ui.provider import ImageProvider from faststack.ui.keystrokes import Keybinder -import threading + +def make_hdrop(paths): + """ + Build a real CF_HDROP (DROPFILES) payload for Windows drag-and-drop. + paths: list[str] + """ + files_part = ("\0".join(paths) + "\0\0").encode("utf-16le") + + # DROPFILES header (20 bytes): = len(self.image_files): + return + + file_path = self.image_files[self.current_index].path + if not file_path.exists(): + log.error(f"File does not exist, cannot start drag: {file_path}") + return + + if self.main_window is None: + return + + drag = QDrag(self.main_window) + mime_data = QMimeData() + + # --- Windows file drop payload --- + if sys.platform.startswith("win"): + hdrop = make_hdrop([str(file_path)]) + mime_data.setData('application/x-qt-windows-mime;value="FileDrop"', hdrop) + mime_data.setData('application/x-qt-windows-mime;value="FileNameW"', + (str(file_path) + "\0").encode("utf-16le")) + mime_data.setData('application/x-qt-windows-mime;value="FileName"', + (str(file_path) + "\0").encode("mbcs", errors="replace")) + else: + mime_data.setUrls([QUrl.fromLocalFile(str(file_path))]) + + drag.setMimeData(mime_data) + + # --- thumbnail / drag preview --- + pix = QPixmap(str(file_path)) + if not pix.isNull(): + # scale it down so it’s not huge + scaled = pix.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation) + drag.setPixmap(scaled) + # hotspot = center of image + drag.setHotSpot(QPoint(scaled.width() // 2, scaled.height() // 2)) + + log.info(f"Starting drag for {file_path}") + drag.exec(Qt.CopyAction) + def _get_stack_info(self, index: int) -> str: info = "" for i, (start, end) in enumerate(self.stacks): @@ -483,6 +689,21 @@ def _get_stack_info(self, index: int) -> str: log.info(f"_get_stack_info for index {index}: {info}") return info + def get_stack_summary(self) -> str: + if not self.stacks: + return "No stacks defined." + summary = [] + for i, (start, end) in enumerate(self.stacks): + summary.append(f"Stack {i+1}: {start}-{end}") + return "; ".join(summary) + + def is_stacked(self) -> bool: + if not self.image_files: + return False + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + return meta.stacked + def main(image_dir: Optional[Path] = typer.Argument(None, help="Directory of images to view")): """FastStack Application Entry Point""" setup_logging() @@ -518,6 +739,7 @@ def main(image_dir: Optional[Path] = typer.Argument(None, help="Directory of ima # Expose controller and UI state to QML context = engine.rootContext() context.setContextProperty("uiState", controller.ui_state) + context.setContextProperty("controller", controller) qml_file = Path(__file__).parent / "qml" / "Main.qml" engine.load(QUrl.fromLocalFile(str(qml_file))) diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index 56bb2b2..4ff8fd9 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -19,6 +19,10 @@ "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", "args": "", }, + "photoshop": { + "exe": "C:\\Program Files\\Adobe\\Adobe Photoshop 2026\\Photoshop.exe", + "args": "", + }, } class AppConfig: diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 6eb9015..95aac22 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -15,22 +15,22 @@ ApplicationWindow { flags: Qt.FramelessWindowHint | Qt.Window title: "FastStack" - Material.theme: isDarkTheme ? Material.Dark : Material.Light + Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light - property bool isDarkTheme: uiState.get_theme() === 0 + property bool isDarkTheme: uiState.theme === 0 property color currentBackgroundColor: isDarkTheme ? "#000000" : "white" property color currentTextColor: isDarkTheme ? "white" : "black" background: Rectangle { color: root.currentBackgroundColor } function toggleTheme() { - uiState.set_theme(isDarkTheme ? 1 : 0) // 0 for dark, 1 for light + uiState.theme = (uiState.theme === 0 ? 1 : 0) // 0 for dark, 1 for light } Connections { target: uiState function onThemeChanged() { - root.isDarkTheme = uiState.get_theme() === 0 + root.isDarkTheme = uiState.theme === 0 } } @@ -109,6 +109,13 @@ ApplicationWindow { font.pixelSize: 16 } } + Label { + id: statusMessageLabel + text: uiState.statusMessage + color: root.currentTextColor + visible: uiState.statusMessage !== "" + Layout.rightMargin: 10 + } } } @@ -156,9 +163,10 @@ ApplicationWindow { text: "&Settings..." onTriggered: { settingsDialog.heliconPath = uiState.get_helicon_path() + settingsDialog.photoshopPath = uiState.get_photoshop_path() settingsDialog.cacheSize = uiState.get_cache_size() settingsDialog.prefetchRadius = uiState.get_prefetch_radius() - settingsDialog.theme = uiState.get_theme() + settingsDialog.theme = uiState.theme settingsDialog.defaultDirectory = uiState.get_default_directory() settingsDialog.open() } @@ -175,6 +183,8 @@ ApplicationWindow { Action { text: "Clear Stacks"; onTriggered: uiState.clear_all_stacks() } Action { text: "Show Stacks"; onTriggered: showStacksDialog.open() } Action { text: "Preload All Images"; onTriggered: uiState.preloadAllImages() } + Action { text: "Filter Images..."; onTriggered: filterDialog.open() } + Action { text: "Clear Filename Filter"; onTriggered: controller.clear_filter() } } Menu { title: "&Help" @@ -237,27 +247,32 @@ ApplicationWindow { color: root.currentBackgroundColor } - contentItem: Text { - text: "FastStack Keyboard and Mouse Commands

" + - "Navigation:
" + - "  J / Right Arrow: Next Image
" + - "  K / Left Arrow: Previous Image

" + - "Viewing:
" + - "  Mouse Wheel: Zoom in/out
" + - "  Left-click + Drag: Pan image
" + - "  G: Toggle Grid View (not implemented)

" + - "Rating & Stacking:
" + - "  Space: Toggle Flag
" + - "  X: Toggle Reject
" + - "  S: Add to selection for Helicon
" + - "  [: Begin new stack
" + - "  ]: End current stack
" + - "  C: Clear all stacks

" + - "Actions:
" + - "  Enter: Launch Helicon Focus" - padding: 10 - wrapMode: Text.WordWrap - color: root.currentTextColor + contentItem: ScrollView { + clip: true + Text { + text: "FastStack Keyboard and Mouse Commands

" + + "Navigation:
" + + "  J / Right Arrow: Next Image
" + + "  K / Left Arrow: Previous Image

" + + "Viewing:
" + + "  Mouse Wheel: Zoom in/out
" + + "  Left-click + Drag: Pan image
" + + "  G: Toggle Grid View (not implemented)

" + + "Rating & Stacking:
" + + "  Space: Toggle Flag
" + + "  X: Toggle Reject
" + + "  S: Add to selection for Helicon
" + + "  [: Begin new stack
" + + "  ]: End current stack
" + + "  C: Clear all stacks

" + + "Actions:
" + + "  Enter: Launch Helicon Focus
" + + "  E: Edit in Photoshop
" + + "  Ctrl+C: Copy image path to clipboard" + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } } } @@ -274,7 +289,7 @@ ApplicationWindow { } contentItem: Text { - text: uiState.get_stack_summary // Access property directly + text: uiState.stackSummary || "No stacks defined." padding: 10 wrapMode: Text.WordWrap color: root.currentTextColor @@ -284,4 +299,13 @@ ApplicationWindow { SettingsDialog { id: settingsDialog } + + FilterDialog { + id: filterDialog + onAccepted: { + controller.apply_filter(filterString) + } +} + + } diff --git a/faststack/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml index d2284fe..809a375 100644 --- a/faststack/faststack/qml/SettingsDialog.qml +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -8,16 +8,18 @@ Dialog { standardButtons: Dialog.Ok | Dialog.Cancel modal: true width: 600 - height: 400 + height: 600 property string heliconPath: "" property double cacheSize: 1.5 property int prefetchRadius: 4 property int theme: 0 property string defaultDirectory: "" + property string photoshopPath: "" onAccepted: { uiState.set_helicon_path(heliconPath) + uiState.set_photoshop_path(photoshopPath) uiState.set_cache_size(cacheSize) uiState.set_prefetch_radius(prefetchRadius) uiState.set_theme(theme) @@ -51,6 +53,30 @@ Dialog { } } + // Photoshop Path + Label { text: "Photoshop Path:" } + TextField { + id: photoshopPathField + Layout.fillWidth: true + text: settingsDialog.photoshopPath + onTextChanged: settingsDialog.photoshopPath = text + } + RowLayout { + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_file_dialog() + if (path) photoshopPathField.text = path + } + } + Label { + id: photoshopCheckMarkLabel + text: "✔" + color: "lightgreen" + visible: uiState.check_path_exists(photoshopPathField.text) + } + } + // Cache Size Label { text: "Cache Size (GB):" } TextField { @@ -105,4 +131,4 @@ Dialog { } } } -} \ No newline at end of file +} diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 812a764..2db830c 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -1,45 +1,88 @@ -"""Maps Qt Key events to application actions.""" - +# faststack/ui/keystrokes.py import logging from PySide6.QtCore import Qt log = logging.getLogger(__name__) class Keybinder: - def __init__(self, main_window): - self.main_window = main_window + def __init__(self, controller): + """ + controller is your AppController. + We will call controller.() by default, + but if controller.main_window has a QML method of the same name, + we'll call that instead so the footer/UI stays in sync. + """ + self.controller = controller + + # map keys → method names (not callables) self.key_map = { # Navigation - Qt.Key.Key_J: self.main_window.next_image, - Qt.Key.Key_Right: self.main_window.next_image, - Qt.Key.Key_K: self.main_window.prev_image, - Qt.Key.Key_Left: self.main_window.prev_image, + Qt.Key_J: "next_image", + Qt.Key_Right: "next_image", + Qt.Key_K: "prev_image", + Qt.Key_Left: "prev_image", # View Mode - Qt.Key.Key_G: self.main_window.toggle_grid_view, + Qt.Key_G: "toggle_grid_view", # Metadata - Qt.Key.Key_Space: self.main_window.toggle_current_flag, - Qt.Key.Key_X: self.main_window.toggle_current_reject, + Qt.Key_Space: "toggle_current_flag", + Qt.Key_X: "toggle_current_reject", # Stacking - Qt.Key.Key_BracketLeft: self.main_window.begin_new_stack, - Qt.Key.Key_BracketRight: self.main_window.end_current_stack, + Qt.Key_BracketLeft: "begin_new_stack", + Qt.Key_BracketRight: "end_current_stack", # Actions - Qt.Key.Key_S: self.main_window.toggle_selection, - Qt.Key.Key_Enter: self.main_window.launch_helicon, - Qt.Key.Key_Return: self.main_window.launch_helicon, + Qt.Key_S: "toggle_selection", + Qt.Key_Enter: "launch_helicon", + Qt.Key_Return: "launch_helicon", + Qt.Key_E: "edit_in_photoshop", + Qt.Key_C: "clear_all_stacks", # Keep C for clear_all_stacks + } - # Stack Management - Qt.Key.Key_C: self.main_window.clear_all_stacks, + self.modifier_key_map = { + (Qt.Key_C, Qt.ControlModifier): "copy_path_to_clipboard", } + def _call(self, method_name: str): + """ + Try QML root first (to keep footer/UI happy), then controller. + """ + mw = getattr(self.controller, "main_window", None) + if mw is not None and hasattr(mw, method_name): + getattr(mw, method_name)() + return + + if hasattr(self.controller, method_name): + getattr(self.controller, method_name)() + return + + log.warning(f"Keybinder: neither main_window nor controller has '{method_name}'") + def handle_key_press(self, event): - """Handles a key press event from the main window.""" - log.info(f"Key pressed: {event.key()}") - action = self.key_map.get(event.key()) - if action: - action() + key = event.key() + text = event.text() + log.info(f"Key pressed: {key} ({text!r}) with modifiers {event.modifiers()}") + + # Check for modifier + key combinations + for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): + if key == mapped_key and event.modifiers() & mapped_modifier: + self._call(method_name) + return True + + # Check for single key presses + method_name = self.key_map.get(key) + if method_name: + self._call(method_name) + return True + + # extra safety for layouts where bracket keycodes are odd + if text == "[": + self._call("begin_new_stack") + return True + if text == "]": + self._call("end_current_stack") return True + return False diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 8e7dbdb..d5fee0e 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -1,10 +1,7 @@ """QML Image Provider and application state bridge.""" import logging -from typing import Optional - -import numpy as np -from PySide6.QtCore import QObject, Signal, Property, QUrl, Slot, Qt +from PySide6.QtCore import QObject, Signal, Property, Slot, Qt from PySide6.QtGui import QImage from PySide6.QtQuick import QQuickImageProvider @@ -12,6 +9,7 @@ log = logging.getLogger(__name__) + class ImageProvider(QQuickImageProvider): def __init__(self, app_controller): super().__init__(QQuickImageProvider.ImageType.Image) @@ -24,14 +22,12 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: if not id: return self.placeholder - # The ID is expected to be the image index try: image_index_str = id.split('/')[0] index = int(image_index_str) image_data = self.app_controller.get_decoded_image(index) if image_data: - # Zero-copy QImage from numpy buffer qimg = QImage( image_data.buffer, image_data.width, @@ -39,15 +35,16 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: image_data.bytes_per_line, QImage.Format.Format_RGB888 ) - # Keep a reference to the original buffer to prevent garbage collection + # keep buffer alive qimg.original_buffer = image_data.buffer return qimg except (ValueError, IndexError) as e: log.error(f"Invalid image ID requested from QML: {id}. Error: {e}") - + return self.placeholder + class UIState(QObject): """Manages the state exposed to the QML user interface.""" @@ -60,13 +57,31 @@ class UIState(QObject): preloadingStateChanged = Signal() preloadProgressChanged = Signal() isZoomedChanged = Signal() + statusMessageChanged = Signal() # New signal for status messages def __init__(self, app_controller): super().__init__() self.app_controller = app_controller self._is_preloading = False self._preload_progress = 0 - + # 1 = light, 0 = dark (controller will overwrite this on startup) + self._theme = 1 + self._status_message = "" # New private variable for status message + + # ---- THEME PROPERTY ---- + @Property(int, notify=themeChanged) + def theme(self): + return self._theme + + @theme.setter + def theme(self, value: int): + value = int(value) + if value == self._theme: + return + self._theme = value + self.themeChanged.emit() + + # ---- ZOOM ---- @Property(bool, notify=isZoomedChanged) def isZoomed(self): return self.app_controller.is_zoomed @@ -75,6 +90,7 @@ def isZoomed(self): def setZoomed(self, zoomed: bool): self.app_controller.set_zoomed(zoomed) + # ---- PRELOADING ---- @Property(bool, notify=preloadingStateChanged) def isPreloading(self): return self._is_preloading @@ -95,6 +111,7 @@ def preloadProgress(self, value): self._preload_progress = value self.preloadProgressChanged.emit() + # ---- IMAGE / METADATA ---- @Property(int, notify=currentIndexChanged) def currentIndex(self): return self.app_controller.current_index @@ -105,11 +122,8 @@ def imageCount(self): @Property(str, notify=currentImageSourceChanged) def currentImageSource(self): - # The source is the provider ID, which we tie to the index and a generation counter - # to force QML to request a new image even if the index is the same. return f"image://provider/{self.app_controller.current_index}/{self.app_controller.ui_refresh_generation}" - # --- Metadata Properties --- @Property(str, notify=metadataChanged) def currentFilename(self): return self.app_controller.get_current_metadata().get("filename", "") @@ -138,13 +152,22 @@ def stackInfoText(self): def get_stack_summary(self): if not self.app_controller.stacks: return "No stacks defined." - summary = f"Found {len(self.app_controller.stacks)} stacks:\n\n" for i, (start, end) in enumerate(self.app_controller.stacks): count = end - start + 1 summary += f"Stack {i+1}: {count} photos (indices {start}-{end})\n" return summary + @Property(str, notify=statusMessageChanged) + def statusMessage(self): + return self._status_message + + @statusMessage.setter + def statusMessage(self, value: str): + if self._status_message != value: + self._status_message = value + self.statusMessageChanged.emit() + # --- Slots for QML to call --- @Slot() def nextImage(self): @@ -174,6 +197,14 @@ def get_helicon_path(self): def set_helicon_path(self, path): self.app_controller.set_helicon_path(path) + @Slot(result=str) + def get_photoshop_path(self): + return self.app_controller.get_photoshop_path() + + @Slot(str) + def set_photoshop_path(self, path): + self.app_controller.set_photoshop_path(path) + @Slot(result=str) def open_file_dialog(self): return self.app_controller.open_file_dialog() @@ -200,10 +231,12 @@ def set_prefetch_radius(self, radius): @Slot(result=int) def get_theme(self): + # this lets QML ask the controller, but the real binding is uiState.theme return self.app_controller.get_theme() @Slot(int) def set_theme(self, theme_index): + # delegate to controller so it can save to config self.app_controller.set_theme(theme_index) @Slot(result=str) @@ -225,3 +258,4 @@ def preloadAllImages(self): @Slot(int, int) def onDisplaySizeChanged(self, width: int, height: int): self.app_controller.on_display_size_changed(width, height) +