From 1acb2bc053675dd50c91aa8707241b77faa960d2 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 21 Nov 2025 01:22:42 -0500 Subject: [PATCH] =?UTF-8?q?Release=20v1.0=20=E2=80=94=20new=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/ChangeLog.md | 30 +++ faststack/README.md | 32 +-- faststack/faststack.egg-info/PKG-INFO | 8 +- faststack/faststack/app.py | 330 ++++++++++++++++++++++---- faststack/faststack/models.py | 6 +- faststack/faststack/qml/Main.qml | 113 +++++---- faststack/faststack/ui/keystrokes.py | 30 ++- faststack/faststack/ui/provider.py | 45 ++-- faststack/pyproject.toml | 2 +- 9 files changed, 467 insertions(+), 129 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 8a0a896..6beb6b3 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -1,5 +1,35 @@ # ChangeLog +## [1.0.0] - 2025-11-21 + +### Major Features +- **Batch Selection System:** New batch selection mode for drag-and-drop operations + - `{` to begin batch, `}` to end batch, `\` to clear all batches + - `X` or `S` keys remove individual images from batches/stacks (shrinks or splits ranges) + - Batches automatically cleared after successful drag operation + - Multiple files can now be dragged to browsers and external applications simultaneously +- **Manual Flag Toggles:** Added keyboard shortcuts to manually control metadata flags + - `U` toggles uploaded flag + - `Ctrl+E` toggles edited flag + - `Ctrl+S` toggles stacked flag +- **Edited Flag Tracking:** New metadata flag for images edited in Photoshop + - Displays "Edited on [date]" in status bar (green) + - Can be manually toggled with `Ctrl+E` +- **Jump to Image Dialog:** Press `G` to jump directly to any image by number + - Dynamic input field sizing based on image count + - Proper keyboard event capture while dialog is open + +### UI/UX Improvements +- **Auto Zoom Reset:** Image view automatically resets to fit-window after drag operations +- **Smooth Window Dragging:** Fixed flickering when dragging title bar by using global coordinates +- **Status Bar Enhancements:** + - Added batch info display (green badge showing position/count) + - Added uploaded status display + - Added edited status display + +### Bug Fixes +- **Multi-file Drag:** Simplified drag implementation to work correctly with Chrome and other browsers + ## [0.9.0] - 2025-11-20 ### Performance Improvements diff --git a/faststack/README.md b/faststack/README.md index 1cfc8e4..ef981c0 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,28 +1,28 @@ # FastStack -# Version 0.9 - November 20, 2025 +# Version 1.0 - November 21, 2025 # By Alan Rockefeller -Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking. +Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive prefetching, and byte-aware LRU caches to provide a fluid experience when reviewing thousands of images. ## Features -- **Instant Navigation:** Sub-10ms next/previous image switching on cache hits. -- **High-Performance Decoding:** Uses `PyTurboJPEG` for fast JPEG decoding, with a fallback to `Pillow`. -- **Zoom & Pan:** Smooth, mipmapped zooming and panning. -- **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). +- **Instant Navigation:** Sub-10ms next/previous image switching, high peformance decoding via `PyTurboJPEG`. +- **Zoom & Pan:** Smooth zooming and panning. - **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). - **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). -- **Sidecar Metadata:** Saves flags, rejections, and stack groupings to a non-destructive `faststack.json` file. -- **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus path via a settings dialog and a persistent `.ini` file. -- **Photoshop Integration:** Edit current image in Photoshop (E key) - automatically uses RAW files when available +- **Photoshop Integration:** Edit current image in Photoshop (E key) - uses RAW files when available - **Clipboard Support:** Copy image path to clipboard (Ctrl+C) - **Image Filtering:** Filter images by filename -- **Drag & Drop:** Drag images to external applications +- **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. - **Theme Support:** Toggle between light and dark themes - **Delete & Undo:** Move images to recycle bin (Delete/Backspace) with undo support (Ctrl+Z) +- **Has Memory**:** Starts where you left off, tells you which images have been edited, stacked and uploaded +- **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). +- **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus / Photoshop paths via a settings dialog and a persistent `.ini` file. +- **Accurate Colors:** Uses monitor ICC profile to display colors correctly. ## Installation & Usage @@ -40,12 +40,16 @@ 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`: Toggle selection of current image for stacking +- `G`: Toggle Grid View (not implemented yet) +- `S` or 'X': Toggle selection of current image for stacking / drag & drop - `[`: Begin new stack group - `]`: End current stack group -- `Space`: Toggle Flag -- `X`: Toggle Reject +- `{`: Begin new drag & drop batch +- `}`: End current drag & drop batch +- '\': Clear drag & drop batch +- 'U': Toggle uploaded flag +- 'Ctrl+E': Toggle edited flag +- 'Ctrl+S': Toggle stacked flag - `Enter`: Launch Helicon Focus with selected RAWs - `E`: Edit in Photoshop (uses RAW file if available) - `Delete` / `Backspace`: Move image to recycle bin diff --git a/faststack/faststack.egg-info/PKG-INFO b/faststack/faststack.egg-info/PKG-INFO index 3906d9b..d2ad528 100644 --- a/faststack/faststack.egg-info/PKG-INFO +++ b/faststack/faststack.egg-info/PKG-INFO @@ -1,7 +1,7 @@ Metadata-Version: 2.4 Name: faststack -Version: 0.8 -Summary: Ultra-fast JPG Viewer for Focus Stacking Selection +Version: 1.0 +Summary: Ultra-fast JPG Viewer for Focus Stacking Selection and website upload Author-email: Alan Rockefeller Classifier: Programming Language :: Python :: 3 Classifier: License :: OSI Approved :: MIT License @@ -21,10 +21,10 @@ Dynamic: license-file # FastStack -# Version 0.8 - November 20, 2025 +# Version 1.0 - November 21, 2025 # By Alan Rockefeller -Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking. +Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking. This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive prefetching, and byte-aware LRU caches to provide a fluid experience when reviewing thousands of images. diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 3945c46..11adf79 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -115,6 +115,11 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self.stack_start_index: Optional[int] = None self.stacks: List[List[int]] = [] self.selected_raws: set[Path] = set() + + # -- Batch Selection State (for drag-and-drop) -- + self.batch_start_index: Optional[int] = None + self.batches: List[List[int]] = [] # List of [start, end] ranges + self._filter_string: str = "" # Default filter self._filter_enabled: bool = False @@ -382,11 +387,11 @@ def sync_ui_state(self): self.ui_state.imageCount ) log.debug( - "Metadata Synced: Filename=%s, Flagged=%s, Rejected=%s, StackInfo='%s'", + "Metadata Synced: Filename=%s, Uploaded=%s, StackInfo='%s', BatchInfo='%s'", self.ui_state.currentFilename, - self.ui_state.isFlagged, - self.ui_state.isRejected, - self.ui_state.stackInfoText + self.ui_state.isUploaded, + self.ui_state.stackInfoText, + self.ui_state.batchInfoText ) @@ -438,6 +443,78 @@ def dialog_closed(self): def toggle_grid_view(self): log.warning("Grid view not implemented yet.") + + def toggle_uploaded(self): + """Toggle uploaded flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.uploaded = not meta.uploaded + if meta.uploaded: + meta.uploaded_date = today + else: + meta.uploaded_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "uploaded" if meta.uploaded else "not uploaded" + self.update_status_message(f"Marked as {status}") + log.info("Toggled uploaded flag to %s for %s", meta.uploaded, stem) + + def toggle_edited(self): + """Toggle edited flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.edited = not meta.edited + if meta.edited: + meta.edited_date = today + else: + meta.edited_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "edited" if meta.edited else "not edited" + self.update_status_message(f"Marked as {status}") + log.info("Toggled edited flag to %s for %s", meta.edited, stem) + + def toggle_stacked(self): + """Toggle stacked flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.stacked = not meta.stacked + if meta.stacked: + meta.stacked_date = today + else: + meta.stacked_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "stacked" if meta.stacked else "not stacked" + self.update_status_message(f"Marked as {status}") + log.info("Toggled stacked flag to %s for %s", meta.stacked, stem) def get_current_metadata(self) -> Dict: if not self.image_files or self.current_index >= len(self.image_files): @@ -456,38 +533,22 @@ def get_current_metadata(self) -> Dict: stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) stack_info = self._get_stack_info(self.current_index) + batch_info = self._get_batch_info(self.current_index) self._metadata_cache = { "filename": self.image_files[self.current_index].path.name, - "flag": meta.flag, - "reject": meta.reject, "stacked": meta.stacked, "stacked_date": meta.stacked_date or "", - "stack_info_text": stack_info + "uploaded": meta.uploaded, + "uploaded_date": meta.uploaded_date or "", + "edited": meta.edited, + "edited_date": meta.edited_date or "", + "stack_info_text": stack_info, + "batch_info_text": batch_info } self._metadata_cache_index = cache_key return self._metadata_cache - def toggle_current_flag(self): - if not self.image_files or self.current_index >= len(self.image_files): - return - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - meta.flag = not meta.flag - self.sidecar.save() - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() - - def toggle_current_reject(self): - if not self.image_files or self.current_index >= len(self.image_files): - return - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - meta.reject = not meta.reject - self.sidecar.save() - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() - def begin_new_stack(self): self.stack_start_index = self.current_index log.info("Stack start marked at index %d", self.stack_start_index) @@ -512,6 +573,118 @@ def end_current_stack(self): self.sync_ui_state() else: log.warning("No stack start marked. Press '[' first.") + + def begin_new_batch(self): + """Mark the start of a new batch for drag-and-drop.""" + self.batch_start_index = self.current_index + log.info("Batch start marked at index %d", self.batch_start_index) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("Batch start marked") + + def end_current_batch(self): + """End the current batch and save the range.""" + log.info("end_current_batch called. batch_start_index: %s", self.batch_start_index) + if self.batch_start_index is not None: + start = min(self.batch_start_index, self.current_index) + end = max(self.batch_start_index, self.current_index) + self.batches.append([start, end]) + self.batches.sort() # Keep batches sorted by start index + log.info("Defined new batch: [%d, %d]", start, end) + self.batch_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + count = end - start + 1 + self.update_status_message(f"Batch defined: {count} images") + else: + log.warning("No batch start marked. Press '{{' first.") + self.update_status_message("No batch start marked") + + def clear_all_batches(self): + """Clear all defined batches.""" + log.info("Clearing all defined batches.") + self.batches = [] + self.batch_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("All batches cleared") + + def remove_from_batch_or_stack(self): + """Remove current image from any batch or stack it's in.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + removed = False + + # Check and remove from batches + for i in range(len(self.batches)): + start, end = self.batches[i] + if start <= self.current_index <= end: + # Build new ranges excluding current_index + new_ranges = [] + if start == end: + # Single image batch - remove entirely (don't add anything) + pass + elif self.current_index == start: + # Remove from beginning - shift start forward + new_ranges.append([start + 1, end]) + elif self.current_index == end: + # Remove from end - shift end backward + new_ranges.append([start, end - 1]) + else: + # Remove from middle - split into two ranges + new_ranges.append([start, self.current_index - 1]) + new_ranges.append([self.current_index + 1, end]) + + # Replace the old range with new range(s) + self.batches[i:i+1] = new_ranges + + log.info("Removed index %d from batch [%d, %d]", self.current_index, start, end) + self.update_status_message(f"Removed from batch") + removed = True + break + + # Check and remove from stacks + if not removed: + for i in range(len(self.stacks)): + start, end = self.stacks[i] + if start <= self.current_index <= end: + # Build new ranges excluding current_index + new_ranges = [] + if start == end: + # Single image stack - remove entirely (don't add anything) + pass + elif self.current_index == start: + # Remove from beginning - shift start forward + new_ranges.append([start + 1, end]) + elif self.current_index == end: + # Remove from end - shift end backward + new_ranges.append([start, end - 1]) + else: + # Remove from middle - split into two ranges + new_ranges.append([start, self.current_index - 1]) + new_ranges.append([self.current_index + 1, end]) + + # Replace the old range with new range(s) + self.stacks[i:i+1] = new_ranges + + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) + self.update_status_message(f"Removed from stack") + removed = True + break + + if removed: + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + else: + self.update_status_message("Not in any batch or stack") def toggle_selection(self): """Toggles the selection status of the current image's file (RAW if available, otherwise JPG).""" @@ -611,8 +784,9 @@ def _delete_temp_file(self, tmp_path: Path): log.error("Error deleting temporary file %s: %s", tmp_path, e) def clear_all_stacks(self): - log.info("Clearing all defined stacks.") + log.info("Clearing all defined stacks and stack start marker.") self.stacks = [] + self.stack_start_index = None # Clear the stack start marker too self.sidecar.data.stacks = self.stacks self.sidecar.save() self._metadata_cache_index = (-1, -1) # Invalidate cache @@ -1068,14 +1242,31 @@ def edit_in_photoshop(self): stderr=subprocess.DEVNULL, close_fds=True # Close unused file descriptors ) + + # Mark as edited on successful launch + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = image_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.edited = True + meta.edited_date = today + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message(f"Opened {current_image_path.name} in Photoshop.") log.info("Launched Photoshop with: %s", command) except FileNotFoundError as e: self.update_status_message(f"Photoshop executable not found: {e}") log.exception("Photoshop executable not found") + # Don't mark as edited if launch failed + return except (OSError, subprocess.SubprocessError) as e: self.update_status_message(f"Failed to open in Photoshop: {e}") log.exception("Error launching Photoshop") + # Don't mark as edited if launch failed + return @Slot() def copy_path_to_clipboard(self): @@ -1113,9 +1304,22 @@ def start_drag_current_image(self): if not self.image_files or self.current_index >= len(self.image_files): return - file_path = self.image_files[self.current_index].path - if not file_path.exists(): - log.error("File does not exist, cannot start drag: %s", file_path) + # Collect all files: current + any in defined batches + files_to_drag = set() + files_to_drag.add(self.current_index) + + # Add all files from defined batches + for start, end in self.batches: + for idx in range(start, end + 1): + if 0 <= idx < len(self.image_files): + files_to_drag.add(idx) + + # Convert to sorted list and get paths + file_indices = sorted(files_to_drag) + file_paths = [self.image_files[idx].path for idx in file_indices if self.image_files[idx].path.exists()] + + if not file_paths: + log.error("No valid files to drag") return if self.main_window is None: @@ -1124,30 +1328,50 @@ def start_drag_current_image(self): 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))]) - + # Use Qt's standard setUrls - it handles both browser and native app compatibility + urls = [QUrl.fromLocalFile(str(p)) for p in file_paths] + mime_data.setUrls(urls) + drag.setMimeData(mime_data) # --- thumbnail / drag preview --- - pix = QPixmap(str(file_path)) + pix = QPixmap(str(file_paths[0])) if not pix.isNull(): - # scale it down so it’s not huge + # 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("Starting drag for %s", file_path) - drag.exec(Qt.CopyAction) + log.info("Starting drag for %d file(s): %s", len(file_paths), [str(p) for p in file_paths]) + # Support both Copy and Move actions for browser compatibility + result = drag.exec(Qt.CopyAction | Qt.MoveAction) + log.info("Drag completed with result: %s", result) + + # Reset zoom/pan after drag completes (drag can cause unwanted panning) + self.ui_state.resetZoomPan() + + # Mark all dragged files as uploaded if drag was successful + if result in (Qt.CopyAction, Qt.MoveAction): + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + + for idx in file_indices: + stem = self.image_files[idx].path.stem + meta = self.sidecar.get_metadata(stem) + meta.uploaded = True + meta.uploaded_date = today + + self.sidecar.save() + + # Clear all batches after successful drag (like pressing \) + self.batches = [] + self.batch_start_index = None + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(file_indices), today) def _get_stack_info(self, index: int) -> str: info = "" @@ -1161,6 +1385,20 @@ def _get_stack_info(self, index: int) -> str: info = "Stack Start Marked" log.debug("_get_stack_info for index %d: %s", index, info) return info + + def _get_batch_info(self, index: int) -> str: + """Get batch info for the given index.""" + info = "" + for i, (start, end) in enumerate(self.batches): + if start <= index <= end: + count_in_batch = end - start + 1 + pos_in_batch = index - start + 1 + info = f"Batch {i+1} ({pos_in_batch}/{count_in_batch})" + break + if not info and self.batch_start_index is not None and self.batch_start_index == index: + info = "Batch Start Marked" + log.debug("_get_batch_info for index %d: %s", index, info) + return info def get_stack_summary(self) -> str: if not self.stacks: diff --git a/faststack/faststack/models.py b/faststack/faststack/models.py index e364dac..094aaa3 100644 --- a/faststack/faststack/models.py +++ b/faststack/faststack/models.py @@ -14,11 +14,13 @@ class ImageFile: @dataclasses.dataclass class EntryMetadata: """Sidecar metadata for a single image entry.""" - flag: bool = False - reject: bool = False stack_id: Optional[int] = None stacked: bool = False stacked_date: Optional[str] = None + uploaded: bool = False + uploaded_date: Optional[str] = None + edited: bool = False + edited_date: Optional[str] = None @dataclasses.dataclass diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 5223ad6..c14b1a5 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -69,17 +69,19 @@ ApplicationWindow { color: root.currentTextColor } Label { - text: uiState.imageCount > 0 ? ` | Flag: ${uiState.isFlagged}` : " | Flag: false" - color: (uiState.imageCount > 0 && uiState.isFlagged) ? "lightgreen" : root.currentTextColor + text: ` | Stacked: ${uiState.stackedDate}` + color: "lightgreen" + visible: uiState.imageCount > 0 && uiState.isStacked } Label { - text: uiState.imageCount > 0 ? ` | Rejected: ${uiState.isRejected}` : " | Rejected: false" - color: (uiState.imageCount > 0 && uiState.isRejected) ? "red" : root.currentTextColor + text: ` | Uploaded on ${uiState.uploadedDate}` + color: "lightgreen" + visible: uiState.imageCount > 0 && uiState.isUploaded } Label { - text: ` | Stacked: ${uiState.stackedDate}` + text: ` | Edited on ${uiState.editedDate}` color: "lightgreen" - visible: uiState.imageCount > 0 && uiState.isStacked + visible: uiState.imageCount > 0 && uiState.isEdited } Label { text: ` | Filter: "${uiState.filterString}"` @@ -102,20 +104,39 @@ ApplicationWindow { } } Rectangle { - Layout.fillWidth: true - color: (uiState.imageCount > 0 && uiState.stackInfoText) ? "orange" : "transparent" // Brighter background + color: (uiState.imageCount > 0 && uiState.stackInfoText) ? "orange" : "transparent" radius: 3 implicitWidth: stackInfoLabel.implicitWidth + 10 implicitHeight: stackInfoLabel.implicitHeight + 5 + visible: uiState.imageCount > 0 && uiState.stackInfoText Label { id: stackInfoLabel anchors.centerIn: parent - text: uiState.imageCount > 0 ? `Stack: ${uiState.stackInfoText || 'N/A'}` : "Stack: N/A" - color: "black" // Black text for contrast on orange + text: `Stack: ${uiState.stackInfoText}` + color: "black" font.bold: true font.pixelSize: 16 } } + Rectangle { + color: (uiState.imageCount > 0 && uiState.batchInfoText) ? "#4fb360" : "transparent" + radius: 3 + implicitWidth: batchInfoLabel.implicitWidth + 10 + implicitHeight: batchInfoLabel.implicitHeight + 5 + visible: uiState.imageCount > 0 && uiState.batchInfoText + Label { + id: batchInfoLabel + anchors.centerIn: parent + text: `Batch: ${uiState.batchInfoText}` + color: "white" + font.bold: true + font.pixelSize: 16 + } + } + Rectangle { + Layout.fillWidth: true + color: "transparent" + } // Saturation slider (only visible in saturation mode) Row { @@ -167,15 +188,16 @@ ApplicationWindow { MouseArea { anchors.fill: parent - property point lastMousePos: Qt.point(0, 0) + property point lastGlobalPos: Qt.point(0, 0) onPressed: function(mouse) { - lastMousePos = Qt.point(mouse.x, mouse.y) + lastGlobalPos = Qt.point(root.x + mouse.x, root.y + mouse.y) } onPositionChanged: function(mouse) { - var delta = Qt.point(mouse.x - lastMousePos.x, mouse.y - lastMousePos.y) + var currentGlobalPos = Qt.point(root.x + mouse.x, root.y + mouse.y) + var delta = Qt.point(currentGlobalPos.x - lastGlobalPos.x, currentGlobalPos.y - lastGlobalPos.y) root.x += delta.x root.y += delta.y - lastMousePos = Qt.point(mouse.x, mouse.y) + lastGlobalPos = currentGlobalPos } } @@ -323,40 +345,49 @@ ApplicationWindow { modal: true closePolicy: Popup.CloseOnEscape focus: true - width: 500 - height: 600 + width: 600 + height: 750 background: Rectangle { color: root.currentBackgroundColor } - contentItem: Text { - text: "FastStack Keyboard and Mouse Commands

" + - "Navigation:
" + - "  J / Right Arrow: Next Image
" + - "  K / Left Arrow: Previous Image
" + - "  G: Jump to Image Number

" + - "Viewing:
" + - "  Mouse Wheel: Zoom in/out
" + - "  Left-click + Drag: Pan image
" + - "  Ctrl+0: Reset zoom and pan to fit window

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

" + - "File Management:
" + - "  Delete: Move current image to recycle bin
" + - "  Ctrl+Z: Undo last delete

" + - "Actions:
" + - "  Enter: Launch Helicon Focus
" + - "  E: Edit in Photoshop
" + - "  Ctrl+C: Copy image path to clipboard" - padding: 10 + contentItem: ScrollView { + clip: true + Text { + text: "FastStack Keyboard and Mouse Commands

" + + "Navigation:
" + + "  J / Right Arrow: Next Image
" + + "  K / Left Arrow: Previous Image
" + + "  G: Jump to Image Number

" + + "Viewing:
" + + "  Mouse Wheel: Zoom in/out
" + + "  Left-click + Drag: Pan image
" + + "  Ctrl+0: Reset zoom and pan to fit window

" + + "Stacking:
" + + "  [: Begin new stack
" + + "  ]: End current stack
" + + "  C: Clear all stacks

" + + "Batch Selection (for drag-and-drop):
" + + "  {: Begin new batch
" + + "  }: End current batch
" + + "  \\: Clear all batches
" + + "  X or S: Remove current image from batch/stack

" + + "Flag Toggles:
" + + "  U: Toggle uploaded flag
" + + "  Ctrl+E: Toggle edited flag
" + + "  Ctrl+S: Toggle stacked flag

" + + "File Management:
" + + "  Delete: Move current image to recycle bin
" + + "  Ctrl+Z: Undo last delete

" + + "Actions:
" + + "  Enter: Launch Helicon Focus
" + + "  E: Edit in Photoshop
" + + "  Ctrl+C: Copy image path to clipboard" + padding: 10 wrapMode: Text.WordWrap color: root.currentTextColor + } } } diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index a79ca3d..90cef44 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -23,20 +23,27 @@ def __init__(self, controller): Qt.Key_Left: "prev_image", Qt.Key_G: "show_jump_to_image_dialog", - # Metadata - Qt.Key_Space: "toggle_current_flag", - Qt.Key_X: "toggle_current_reject", - # Stacking Qt.Key_BracketLeft: "begin_new_stack", Qt.Key_BracketRight: "end_current_stack", + + # Batching + Qt.Key_BraceLeft: "begin_new_batch", + Qt.Key_BraceRight: "end_current_batch", + Qt.Key_Backslash: "clear_all_batches", + + # Remove from batch/stack + Qt.Key_X: "remove_from_batch_or_stack", + Qt.Key_S: "remove_from_batch_or_stack", + + # Toggle flags + Qt.Key_U: "toggle_uploaded", # Actions - 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 + Qt.Key_C: "clear_all_stacks", Qt.Key_Delete: "delete_current_image", Qt.Key_Backspace: "delete_current_image", } @@ -45,6 +52,8 @@ def __init__(self, controller): (Qt.Key_C, Qt.ControlModifier): "copy_path_to_clipboard", (Qt.Key_0, Qt.ControlModifier): "reset_zoom_pan", (Qt.Key_Z, Qt.ControlModifier): "undo_delete", + (Qt.Key_E, Qt.ControlModifier): "toggle_edited", + (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", } def _call(self, method_name: str): @@ -86,5 +95,14 @@ def handle_key_press(self, event): if text == "]": self._call("end_current_stack") return True + if text == "{": + self._call("begin_new_batch") + return True + if text == "}": + self._call("end_current_batch") + return True + if text == "\\": + self._call("clear_all_batches") + return True return False diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 2c6e800..93e9881 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -156,18 +156,6 @@ def currentFilename(self): return "" return self.app_controller.get_current_metadata().get("filename", "") - @Property(bool, notify=metadataChanged) - def isFlagged(self): - if not self.app_controller.image_files: - return False - return self.app_controller.get_current_metadata().get("flag", False) - - @Property(bool, notify=metadataChanged) - def isRejected(self): - if not self.app_controller.image_files: - return False - return self.app_controller.get_current_metadata().get("reject", False) - @Property(bool, notify=metadataChanged) def isStacked(self): if not self.app_controller.image_files: @@ -185,6 +173,36 @@ def stackInfoText(self): if not self.app_controller.image_files: return "" return self.app_controller.get_current_metadata().get("stack_info_text", "") + + @Property(bool, notify=metadataChanged) + def isUploaded(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("uploaded", False) + + @Property(str, notify=metadataChanged) + def uploadedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("uploaded_date", "") + + @Property(str, notify=metadataChanged) + def batchInfoText(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("batch_info_text", "") + + @Property(bool, notify=metadataChanged) + def isEdited(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("edited", False) + + @Property(str, notify=metadataChanged) + def editedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("edited_date", "") @Property(str, notify=stackSummaryChanged) def stackSummary(self): @@ -235,9 +253,6 @@ def nextImage(self): def prevImage(self): self.app_controller.prev_image() - @Slot() - def toggleFlag(self): - self.app_controller.toggle_current_flag() @Slot() def launch_helicon(self): diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index 1098412..ae403f2 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "0.9" +version = "1.0" authors = [ { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, ]