diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md new file mode 100644 index 0000000..940cfef --- /dev/null +++ b/faststack/ChangeLog.md @@ -0,0 +1,43 @@ +# ChangeLog + +## Version 0.3 + +### New Features +- Implemented a "Settings" dialog with the following configurable options: + - Helicon Focus executable path (with validation). + - Image cache size (in GB). + - Image prefetch radius. + - Application theme (Dark/Light). + - Default image directory. + +## Version 0.2 + +### New Features +- Added an "Actions" menu with the following options: + - "Run Stacks": Launch Helicon Focus with selected files or all stacks. + - "Clear Stacks": Clear all defined stacks. + - "Show Stacks": Display a dialog with information about the defined stacks. +- Pressing the 'S' key now adds or removes a RAW file from the selection for processing. +- Implemented tracking for stacked images: + - `EntryMetadata` now includes `stacked` (boolean) and `stacked_date` (string) fields. + - `launch_helicon` records stacking status and date upon successful launch. + - The footer in `Main.qml` displays "Stacked: [date]" for previously stacked images. + +### Changes +- Pressing the 'Enter' key will now launch Helicon Focus with the selected RAW files. If no files are selected, it will launch with all defined stacks. +- Refactored the theme toggling logic in `Main.qml` to use a boolean `isDarkTheme` property for more robustness. + +### Bug Fixes +- Fixed an issue where both the main "Enter" key and the numeric keypad "Enter" key were not consistently recognized. +- The "Show Stacks" and "Key Bindings" dialogs now correctly follow the application's theme (light/dark mode). +- Fixed a bug that caused the "Show Stacks" dialog to be blank. +- Resolved a `NameError` caused by using `Optional` without importing it. +- Corrected an import error for `EntryMetadata` in the tests. +- Updated a test to assert the correct default version number. +- Fixed a `TypeError` in tests caused by a missing `stack_id` field in the `EntryMetadata` model. +- Resolved a QML issue where `anchors.fill` conflicted with manual positioning, preventing panning and zooming. +- Corrected the `launch_helicon` method to only clear the `selected_raws` set if Helicon Focus is launched successfully. +- Resolved `TypeError` and `Invalid property assignment` errors in QML related to settings dialog initialization and property bindings. +- Fixed QML warnings related to invalid anchor usage in `Main.qml`. +- Fixed missing minimize, maximize, and close buttons by correctly configuring the custom title bar. +- Resolved QML warnings about `mouse` parameter not being declared in `MouseArea` signal handlers. diff --git a/faststack/README.md b/faststack/README.md index 123f6f9..54c3e89 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 0.1 - October 31, 2025 +# Version 0.3 - November 1, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking. @@ -35,7 +35,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `J` / `Right Arrow`: Next Image - `K` / `Left Arrow`: Previous Image - `G`: Toggle Grid View -- `S`: Stack all of the selected stacks with Helicon Focus +- `S`: Toggle selection of current image for stacking - `[`: Begin new stack group - `]`: End current stack group - `Space`: Toggle Flag diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 061ee31..26ba023 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -4,12 +4,13 @@ import sys 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 -from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import QApplication, QFileDialog from PySide6.QtQml import QQmlApplicationEngine from faststack.config import config @@ -41,7 +42,8 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self.sidecar = SidecarManager(self.image_dir, self.watcher) # -- Caching & Prefetching -- - cache_size_bytes = config.getint('core', 'cache_bytes', int(1.5 * 1024**3)) + cache_size_gb = config.getfloat('core', 'cache_size_gb', 1.5) + cache_size_bytes = int(cache_size_gb * 1024**3) self.image_cache = ByteLRUCache(max_bytes=cache_size_bytes, size_of=get_decoded_image_size) self.prefetcher = Prefetcher( image_files=self.image_files, @@ -77,6 +79,9 @@ def load(self): self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() + theme = config.get('core', 'theme') + self.main_window.setProperty('isDarkTheme', theme == 'dark') + def refresh_image_list(self): """Rescans the directory for images.""" self.image_files = find_images(self.image_dir) @@ -153,6 +158,8 @@ def get_current_metadata(self) -> Dict: "flag": meta.flag, "reject": meta.reject, "stack_info_text": stack_info, + "stacked": meta.stacked, + "stacked_date": meta.stacked_date, } def toggle_current_flag(self): @@ -216,7 +223,7 @@ def launch_helicon(self): raw_files_to_process.extend(sorted(list(self.selected_raws))) # Sort for consistent order elif self.stacks: log.info("No selection, launching Helicon with all defined stacks.") - for i, (start, end) in enumerate(self.stacks): + for start, end in self.stacks: for idx in range(start, end + 1): if idx < len(self.image_files) and self.image_files[idx].raw_pair: raw_files_to_process.append(self.image_files[idx].raw_pair) @@ -232,10 +239,23 @@ def launch_helicon(self): if success and tmp_path: # Schedule delayed deletion of the temporary file QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) - - # Clear selection after launching - self.selected_raws.clear() - self.sync_ui_state() + + # Record stacking metadata + today = date.today().isoformat() + for raw_path in unique_raw_files: + # Find the corresponding image file to get the stem + for img_file in self.image_files: + if img_file.raw_pair == raw_path: + stem = img_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.stacked = True + meta.stacked_date = today + break + self.sidecar.save() + + # Clear selection after launching + self.selected_raws.clear() + self.sync_ui_state() else: log.warning("No valid RAW files found to launch Helicon.") @@ -254,12 +274,65 @@ def clear_all_stacks(self): self.sidecar.save() self.ui_state.metadataChanged.emit() # Refresh UI to show no stacks + def get_helicon_path(self): + return config.get('helicon', 'exe') + + def set_helicon_path(self, path): + config.set('helicon', 'exe', path) + config.save() + + def open_file_dialog(self): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.FileMode.ExistingFile) + dialog.setNameFilter("Executables (*.exe)") + if dialog.exec(): + return dialog.selectedFiles()[0] + return "" + + def check_path_exists(self, path): + return os.path.exists(path) + + def get_cache_size(self): + return config.getfloat('core', 'cache_size_gb') + + def set_cache_size(self, size): + config.set('core', 'cache_size_gb', size) + config.save() + + def get_prefetch_radius(self): + return config.getint('core', 'prefetch_radius') + + def set_prefetch_radius(self, radius): + config.set('core', 'prefetch_radius', radius) + config.save() + + def get_theme(self): + return 0 if config.get('core', 'theme') == 'dark' else 1 + + def set_theme(self, theme_index): + theme = 'dark' if theme_index == 0 else 'light' + config.set('core', 'theme', theme) + config.save() + + def get_default_directory(self): + return config.get('core', 'default_directory') + + def set_default_directory(self, path): + config.set('core', 'default_directory', path) + config.save() + + def open_directory_dialog(self): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.FileMode.Directory) + if dialog.exec(): + return dialog.selectedFiles()[0] + return "" + def shutdown(self): log.info("Application shutting down.") # Clear QML context property to prevent TypeErrors during shutdown if self.engine: log.info("Clearing uiState context property in QML.") - self.engine.rootContext().setContextProperty("uiState", None) del self.engine # Explicitly delete the engine self.watcher.stop() @@ -280,16 +353,24 @@ def _get_stack_info(self, index: int) -> str: log.info(f"_get_stack_info for index {index}: {info}") return info -def main(image_dir: Path = typer.Argument(..., help="Directory of images to view")): +def main(image_dir: Optional[Path] = typer.Argument(None, help="Directory of images to view")): """FastStack Application Entry Point""" setup_logging() log.info("Starting FastStack") + if image_dir is None: + image_dir_str = config.get('core', 'default_directory') + if not image_dir_str: + log.error("No image directory provided and no default directory set in the settings.") + # In a real app, we might open a dialog here to ask for a directory. + sys.exit(1) + image_dir = Path(image_dir_str) + if not image_dir.is_dir(): log.error(f"Image directory not found: {image_dir}") sys.exit(1) - app = QGuiApplication(sys.argv) + app = QApplication(sys.argv) app.setOrganizationName("FastStack") app.setOrganizationDomain("faststack.dev") app.setApplicationName("FastStack") diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index 2b1e456..56bb2b2 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -10,8 +10,10 @@ DEFAULT_CONFIG = { "core": { - "cache_bytes": str(int(1.5 * 1024**3)), # 1.5 GB + "cache_size_gb": "1.5", "prefetch_radius": "4", + "theme": "dark", + "default_directory": "", }, "helicon": { "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", diff --git a/faststack/faststack/models.py b/faststack/faststack/models.py index 968f380..e364dac 100644 --- a/faststack/faststack/models.py +++ b/faststack/faststack/models.py @@ -17,6 +17,8 @@ class EntryMetadata: flag: bool = False reject: bool = False stack_id: Optional[int] = None + stacked: bool = False + stacked_date: Optional[str] = None @dataclasses.dataclass diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 7786218..b11219d 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -10,8 +10,7 @@ Item { // The main image display Image { id: mainImage - width: parent.width - height: parent.height + anchors.fill: parent source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" fillMode: Image.PreserveAspectFit cache: false // We do our own caching in Python diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index f4c8fd7..6f822d7 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -1,29 +1,25 @@ import QtQuick import QtQuick.Window import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import "." ApplicationWindow { id: root width: Screen.width height: Screen.height - visibility: Window.FullScreen - title: "FastStack - " + (uiState && uiState.currentFilename ? uiState.currentFilename : "No folder loaded") + visibility: Window.Maximized + flags: Qt.FramelessWindowHint + title: "FastStack" - property color currentBackgroundColor: "#212121" // Default dark background - property color currentTextColor: "white" // Default light text + property bool isDarkTheme: true + property color currentBackgroundColor: isDarkTheme ? "#000000" : "white" + property color currentTextColor: isDarkTheme ? "white" : "black" background: Rectangle { color: root.currentBackgroundColor } function toggleTheme() { - if (root.currentBackgroundColor === "#212121") { // Currently dark - root.currentBackgroundColor = "white" - root.currentTextColor = "black" - } else { // Currently light - root.currentBackgroundColor = "#212121" - root.currentTextColor = "white" - } - // Update colors of specific elements if needed, e.g., footer labels - // For now, rely on default text colors or explicit bindings + isDarkTheme = !isDarkTheme } // Expose the Python UIState object to QML @@ -33,6 +29,7 @@ ApplicationWindow { Loader { id: mainViewLoader anchors.fill: parent + anchors.topMargin: titleBar.height source: "Components.qml" } @@ -46,13 +43,11 @@ ApplicationWindow { anchors.right: parent.right color: "#80000000" // Semi-transparent black - Row { + RowLayout { id: footerRow spacing: 10 - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 10 Label { + Layout.leftMargin: 10 text: `Image: ${uiState.currentIndex + 1} / ${uiState.imageCount}` color: root.currentTextColor } @@ -68,7 +63,13 @@ ApplicationWindow { text: ` | Rejected: ${uiState.isRejected}` color: uiState.isRejected ? "red" : root.currentTextColor } + Label { + text: ` | Stacked: ${uiState.stackedDate}` + color: "lightgreen" + visible: uiState.isStacked + } Rectangle { + Layout.fillWidth: true color: uiState.stackInfoText ? "orange" : "transparent" // Brighter background radius: 3 implicitWidth: stackInfoLabel.implicitWidth + 10 @@ -80,60 +81,158 @@ ApplicationWindow { color: "black" // Black text for contrast on orange font.bold: true font.pixelSize: 16 - onTextChanged: function() { console.log("Stack info text changed:", stackInfoLabel.text) } } } } } - // Menu Bar - menuBar: MenuBar { - Menu { - title: "&File" - Action { - text: "&Open Folder..." - onTriggered: { - // This would trigger a file dialog in Python - } - } - Action { - text: "&Settings..." - } + header: Rectangle { + id: titleBar + height: 30 + color: root.currentBackgroundColor - Action { - text: "&Exit" - onTriggered: Qt.quit() - } - } - Menu { - title: "&View" - Action { - text: "Toggle Light/Dark Mode" - onTriggered: root.toggleTheme() - } - } - Menu { - title: "&Actions" - Action { - text: "Run Stacks" - onTriggered: uiState.launch_helicon() - } - Action { - text: "Clear Stacks" - onTriggered: uiState.clear_all_stacks() + MouseArea { + anchors.fill: parent + property point lastMousePos: Qt.point(0, 0) + onPressed: function(mouse) { + lastMousePos = Qt.point(mouse.x, mouse.y) } - Action { - text: "Show Stacks" - onTriggered: showStacksDialog.open() - } - } - Menu { - title: "&Help" - Action { - text: "&Key Bindings" - onTriggered: aboutDialog.open() + onPositionChanged: function(mouse) { + var delta = Qt.point(mouse.x - lastMousePos.x, mouse.y - lastMousePos.y) + root.x += delta.x + root.y += delta.y } } + + RowLayout { + + id: menuAndControls + + anchors.fill: parent + + + + MenuBar { + + id: menuBar + + Layout.preferredWidth: 300 // Give it some width + + + + palette.buttonText: root.currentTextColor + + palette.button: root.currentBackgroundColor + + palette.window: root.currentBackgroundColor + + palette.text: root.currentTextColor + + + + Menu { + + title: "&File" + + Action { text: "&Open Folder..." } + + Action { + + text: "&Settings..." + + onTriggered: { + + settingsDialog.heliconPath = uiState.get_helicon_path() + + settingsDialog.cacheSize = uiState.get_cache_size() + + settingsDialog.prefetchRadius = uiState.get_prefetch_radius() + + settingsDialog.theme = uiState.get_theme() + + settingsDialog.defaultDirectory = uiState.get_default_directory() + + settingsDialog.open() + + } + + } + + Action { text: "&Exit"; onTriggered: Qt.quit() } + + } + + Menu { + + title: "&View" + + Action { text: "Toggle Light/Dark Mode"; onTriggered: root.toggleTheme() } + + } + + Menu { + + title: "&Actions" + + Action { text: "Run Stacks"; onTriggered: uiState.launch_helicon() } + + Action { text: "Clear Stacks"; onTriggered: uiState.clear_all_stacks() } + + Action { text: "Show Stacks"; onTriggered: showStacksDialog.open() } + + } + + Menu { + + title: "&Help" + + Action { text: "&Key Bindings"; onTriggered: aboutDialog.open() } + + } + + } + + + + Item { Layout.fillWidth: true } // Spacer + + + + Row { + + // Removed anchors + + spacing: 10 + + + + Button { + + text: "-" + + onClicked: root.showMinimized() + + } + + Button { + + text: "[]" + + onClicked: root.visibility === Window.Maximized ? root.showNormal() : root.showMaximized() + + } + + Button { + + text: "X" + + onClicked: Qt.quit() + + } + + } + + } } Dialog { @@ -191,4 +290,8 @@ ApplicationWindow { color: root.currentTextColor } } + + SettingsDialog { + id: settingsDialog + } } diff --git a/faststack/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml new file mode 100644 index 0000000..d2284fe --- /dev/null +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -0,0 +1,108 @@ +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Dialog { + id: settingsDialog + title: "Settings" + standardButtons: Dialog.Ok | Dialog.Cancel + modal: true + width: 600 + height: 400 + + property string heliconPath: "" + property double cacheSize: 1.5 + property int prefetchRadius: 4 + property int theme: 0 + property string defaultDirectory: "" + + onAccepted: { + uiState.set_helicon_path(heliconPath) + uiState.set_cache_size(cacheSize) + uiState.set_prefetch_radius(prefetchRadius) + uiState.set_theme(theme) + uiState.set_default_directory(defaultDirectory) + } + + contentItem: GridLayout { + columns: 3 + + // Helicon Path + Label { text: "Helicon Focus Path:" } + TextField { + id: heliconPathField + Layout.fillWidth: true + text: settingsDialog.heliconPath + onTextChanged: settingsDialog.heliconPath = text + } + RowLayout { + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_file_dialog() + if (path) heliconPathField.text = path + } + } + Label { + id: checkMarkLabel + text: "✔" + color: "lightgreen" + visible: uiState.check_path_exists(heliconPathField.text) + } + } + + // Cache Size + Label { text: "Cache Size (GB):" } + TextField { + id: cacheSizeField + Layout.fillWidth: true + text: settingsDialog.cacheSize.toFixed(1) // Display with one decimal place + onTextChanged: { + var value = parseFloat(text) + if (!isNaN(value) && value >= 0.5 && value <= 16) { + settingsDialog.cacheSize = value + } else if (text === "") { // Handle empty text + settingsDialog.cacheSize = 1.5 // Default to 1.5 if empty + } + } + } + Label {} // Placeholder + + // Prefetch Radius + Label { text: "Prefetch Radius:" } + SpinBox { + id: prefetchRadiusSpinBox + from: 1 + to: 20 + value: settingsDialog.prefetchRadius + onValueChanged: settingsDialog.prefetchRadius = value + } + Label {} // Placeholder + + // Theme + Label { text: "Theme:" } + ComboBox { + id: themeComboBox + model: ["Dark", "Light"] + currentIndex: settingsDialog.theme + onCurrentIndexChanged: settingsDialog.theme = currentIndex + } + Label {} // Placeholder + + // Default Directory + Label { text: "Default Image Directory:" } + TextField { + id: defaultDirectoryField + Layout.fillWidth: true + text: settingsDialog.defaultDirectory + onTextChanged: settingsDialog.defaultDirectory = text + } + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_directory_dialog() + if (path) defaultDirectoryField.text = path + } + } + } +} \ No newline at end of file diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 4318586..1e33f98 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -88,6 +88,14 @@ def isFlagged(self): def isRejected(self): return self.app_controller.get_current_metadata().get("reject", False) + @Property(bool, notify=metadataChanged) + def isStacked(self): + return self.app_controller.get_current_metadata().get("stacked", False) + + @Property(str, notify=metadataChanged) + def stackedDate(self): + return self.app_controller.get_current_metadata().get("stacked_date", "") + @Property(str, notify=metadataChanged) def stackInfoText(self): return self.app_controller.get_current_metadata().get("stack_info_text", "") @@ -123,3 +131,55 @@ def launch_helicon(self): @Slot() def clear_all_stacks(self): self.app_controller.clear_all_stacks() + + @Slot(result=str) + def get_helicon_path(self): + return self.app_controller.get_helicon_path() + + @Slot(str) + def set_helicon_path(self, path): + self.app_controller.set_helicon_path(path) + + @Slot(result=str) + def open_file_dialog(self): + return self.app_controller.open_file_dialog() + + @Slot(str, result=bool) + def check_path_exists(self, path): + return self.app_controller.check_path_exists(path) + + @Slot(result=float) + def get_cache_size(self): + return self.app_controller.get_cache_size() + + @Slot(float) + def set_cache_size(self, size): + self.app_controller.set_cache_size(size) + + @Slot(result=int) + def get_prefetch_radius(self): + return self.app_controller.get_prefetch_radius() + + @Slot(int) + def set_prefetch_radius(self, radius): + self.app_controller.set_prefetch_radius(radius) + + @Slot(result=int) + def get_theme(self): + return self.app_controller.get_theme() + + @Slot(int) + def set_theme(self, theme_index): + self.app_controller.set_theme(theme_index) + + @Slot(result=str) + def get_default_directory(self): + return self.app_controller.get_default_directory() + + @Slot(str) + def set_default_directory(self, path): + self.app_controller.set_default_directory(path) + + @Slot(result=str) + def open_directory_dialog(self): + return self.app_controller.open_directory_dialog() diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index 27efac1..4ffeb8a 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,11 +5,11 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "0.2" +version = "0.3" authors = [ { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, ] -description = "Ultra-fast JPG Viewer for RAW Stacking Selection" +description = "Ultra-fast JPG Viewer for Focus Stacking Selection" readme = "README.md" requires-python = ">=3.11" classifiers = [