From ce2bc77a2ffa5c45472c1b1e77d565d6bfdeaae6 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 1 Jan 2026 15:27:10 -0800 Subject: [PATCH 1/6] Fixed whites slider --- faststack/ChangeLog.md | 9 +- faststack/README.md | 3 +- faststack/faststack/app.py | 119 ++++++++- faststack/faststack/config.py | 6 +- faststack/faststack/debug_output.txt | 13 - faststack/faststack/faststack.json | 6 - faststack/faststack/imaging/cache.py | 4 +- faststack/faststack/imaging/editor.py | 39 +-- faststack/faststack/imaging/jpeg.py | 5 +- faststack/faststack/imaging/prefetch.py | 5 + faststack/faststack/io/helicon.py | 2 + faststack/faststack/models.py | 2 + faststack/faststack/next.prompt | 2 +- faststack/faststack/qml/Components.qml | 15 +- faststack/faststack/qml/DeleteBatchDialog.qml | 2 +- faststack/faststack/qml/FilterDialog.qml | 20 +- faststack/faststack/qml/HistogramWindow.qml | 86 ++++--- faststack/faststack/qml/ImageEditorDialog.qml | 240 +++++++++++------- faststack/faststack/qml/Main.qml | 11 +- .../faststack/tests/dummy_images/test.jpg | Bin 0 -> 7165 bytes faststack/faststack/tests/test_editor.py | 4 +- .../faststack/tests/test_editor_rotation.py | 10 +- .../tests/test_executable_validator.py | 3 +- faststack/faststack/tests/test_pairing.py | 2 +- faststack/faststack/ui/provider.py | 45 +++- faststack/pyproject.toml | 4 + faststack/test_output.txt | Bin 0 -> 788 bytes 27 files changed, 426 insertions(+), 231 deletions(-) delete mode 100644 faststack/faststack/debug_output.txt delete mode 100644 faststack/faststack/faststack.json create mode 100644 faststack/faststack/tests/dummy_images/test.jpg create mode 100644 faststack/test_output.txt diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index bed9d5d..fdd9d14 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -13,8 +13,7 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better docum - Changed how image caching works for even faster display. - Pressing H brings up a RGB histogram which is designed to show even a little bit of highlight clipping and updates as you zoom in. - Added batch delete with confirmation dialog. -- Added the --cachedebug command line argument which gives info on the image cache in the status bar. Doesn't seem to slow down the program at all, just takes up room in the status bar.A -- Added a setting that switches between image display optimized for speed or quality. +- Added the --cachedebug command line argument which gives info on the image cache in the status bar. Doesn't seem to slow down the program at all, just takes up room in the status bar.- Added a setting that switches between image display optimized for speed or quality. - **Auto-Levels:** Automatic image enhancement with configurable threshold and strength (L key) - **Image Metadata:** Extract and display EXIF metadata (I key) - **Image Processing:** Auto white balance, texture enhancement, and straightening @@ -104,10 +103,10 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better docum ### Features - **JPG Fallback for Helicon:** Helicon Focus stacking now works with JPG-only workflows when RAW files absent. - **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis.- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). -## [0.8.0] - 2025-11-20 +- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis. +- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). -### Added -- Backspace key now deletes images (in addition to Delete key). Control-Z restores. +## [0.8.0] - 2025-11-20- Backspace key now deletes images (in addition to Delete key). Control-Z restores. - Photoshop integration now automatically uses RAW files when available, falling back to JPG. - We now have some new color modes in the view menu to make the images in your monitor reflect reality. ICC profile mode works best on my system - try it if the images are over-saturated - or turn down the saturation in saturation mode. Test it out by loading an image in Faststack and Photoshop or another image viewer and make sure the colors look the same. diff --git a/faststack/README.md b/faststack/README.md index 0950f89..63eec14 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -15,8 +15,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). - **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. - **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) -- **Quick Auto White Balance:** Press A to apply auto white balance and save automatically with undo support (Ctrl+Z). For better white balance load the raw into Photoshop with the P key. -- **Photoshop Integration:** Edit current image in Photoshop (P key) - always uses RAW files when available. +- **Quick Auto White Balance:** Press A to apply auto white balance and save automatically with undo support (Ctrl+Z). For better white balance, load the raw into Photoshop with the P key.- **Photoshop Integration:** Edit current image in Photoshop (P key) - always 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. Press { and } to batch files to drag & drop multiple images. diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 945a6c2..fac744a 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -665,6 +665,30 @@ def toggle_edited(self): self.update_status_message(f"Marked as {status}") log.info("Toggled edited flag to %s for %s", meta.edited, stem) + def toggle_restacked(self): + """Toggle restacked 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.restacked = not meta.restacked + if meta.restacked: + meta.restacked_date = today + else: + meta.restacked_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "restacked" if meta.restacked else "not restacked" + self.update_status_message(f"Marked as {status}") + log.info("Toggled restacked flag to %s for %s", meta.restacked, stem) + def toggle_stacked(self): """Toggle stacked flag for current image.""" if not self.image_files or self.current_index >= len(self.image_files): @@ -716,6 +740,8 @@ def get_current_metadata(self) -> Dict: "uploaded_date": meta.uploaded_date or "", "edited": meta.edited, "edited_date": meta.edited_date or "", + "restacked": meta.restacked, + "restacked_date": meta.restacked_date or "", "stack_info_text": stack_info, "batch_info_text": batch_info } @@ -1401,6 +1427,9 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): if left < 0: left = 0 + self.ui_state.currentCropBox = (left, top, right, bottom) + self.image_editor.set_crop_box((left, top, right, bottom)) + log.debug(f"AppController.set_straighten_angle: {angle}") # Invert angle because QML rotation is CW but PIL rotation (used in editor) handles direction logic internally # (ImageEditor._apply_edits uses negative angle for PIL). @@ -1783,10 +1812,23 @@ def delete_batch_images(self): # This way indices don't shift as we delete sorted_indices = sorted(indices_to_delete, reverse=True) - previous_index = self.current_index - preserved_path = None - if self.image_files and self.current_index not in indices_to_delete: - preserved_path = self.image_files[self.current_index].path + # Determine where to land after deletion + # We prefer to land on the image that was *conceptually* at the same position, + # which means following the last deleted index if we were deleting from right to left, + # or just staying at the start index of the batch. + + # If we just deleted a batch at the end of the list, we clamp to new length-1 + # If we deleted a batch in the middle, we want to be at the index that *was* + # immediately after the batch (which now shifts down by deleted_count). + + # Simpler logic: + # If we had a batch starting at index S with N items. + # After deleting N items, the item that was at S+N matches the new item at S. + # So we should generally effectively stay at 'start' (which finds the next image). + # We need to find the smallest index that was part of the deletion. + min_deleted_index = min(sorted_indices) + + previous_index = self.current_index # This might be inside the deleted range # Create recycle bin if it doesn't exist try: @@ -1835,8 +1877,16 @@ def delete_batch_images(self): # Refresh image list self.refresh_image_list() + if self.image_files: - self._reposition_after_delete(preserved_path, previous_index) + # Calculate new index + # We essentially want to be at 'min_deleted_index' + # But clamped to boundaries. + new_index = min_deleted_index + new_index = max(0, min(new_index, len(self.image_files) - 1)) + + self.current_index = new_index + # Clear cache and invalidate display generation to force image reload self.display_generation += 1 self.image_cache.clear() @@ -2617,6 +2667,46 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = log.exception("Failed to compute histogram: %s", e) self.update_status_message(f"Histogram error: {e}") + @Slot() + def execute_crop(self): + """Execute crop.""" + if not self.ui_state.isCropping: + return + + try: + crop_box_raw = self.ui_state.currentCropBox + + if isinstance(crop_box_raw, list): + crop_box_raw = tuple(crop_box_raw) + + # if it is a QVariant, try toVariant + if not isinstance(crop_box_raw, tuple) and hasattr(crop_box_raw, "toVariant"): + try: + crop_box_raw = tuple(crop_box_raw.toVariant()) + except Exception: + pass + + if not (isinstance(crop_box_raw, tuple) and len(crop_box_raw) == 4): + self.update_status_message("Invalid crop box") + return + + # Apply crop by setting the crop box (this also updates UI state via set_crop_box) + # Ensure values are ints + left, top, right, bottom = map(int, crop_box_raw) + self.set_crop_box(left, top, right, bottom) + + # Exit crop mode + self.ui_state.isCropping = False + + # Apply edits (which includes crop) to preview + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.update_status_message("Crop applied") + + except Exception as e: + log.exception("Failed to execute crop: %s", e) + self.update_status_message(f"Crop failed: {e}") + @Slot() def cancel_crop_mode(self): """Cancel crop mode without applying changes.""" @@ -2793,6 +2883,18 @@ def stack_source_raws(self): success = self._launch_helicon_with_files(found_raw_files) if success: + # Mark as restacked on success + 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.restacked = True + meta.restacked_date = today + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("Helicon Focus launched successfully.") else: self.update_status_message("Failed to launch Helicon Focus.") @@ -2815,16 +2917,15 @@ def execute_crop(self): current_rotation = float(self.image_editor.current_edits.get("straighten_angle", 0.0)) # Ensure ImageEditor has the latest crop box (it should be synced via UIState, but good to be safe) - crop_box_raw = self.ui_state.currentCropBox - # ... (validation code remains similar or can be simplified since UIState validates) ... - # For robustness, we'll trust UIState's validation or do a quick check if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: # Try to convert if it came as list try: crop_box_raw = tuple(crop_box_raw) if isinstance(crop_box_raw, list) else tuple(crop_box_raw.toVariant()) - except: + except Exception: pass + if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: pass + if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: self.update_status_message("Invalid crop box") return diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index 6400992..e4f6b3b 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -15,9 +15,9 @@ "theme": "dark", "default_directory": "", "optimize_for": "speed", # "speed" or "quality" - "auto_level_threshold": "0.1", - "auto_level_strength": "1.0", - "auto_level_strength_auto": "False", + "auto_level_threshold": "0.1", # Threshold for auto-level detection (0.0-1.0) + "auto_level_strength": "1.0", # Strength of auto-level correction (0.0-1.0) + "auto_level_strength_auto": "False", # Automatically adjust auto-level strength }, "helicon": { "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", diff --git a/faststack/faststack/debug_output.txt b/faststack/faststack/debug_output.txt deleted file mode 100644 index 55eb5c7..0000000 --- a/faststack/faststack/debug_output.txt +++ /dev/null @@ -1,13 +0,0 @@ -Starting debug test... -Calling get_exif_data... -Result Summary: { - "Date Taken": "2023:01:01 12:00:00", - "Camera": "Canon EOS R5", - "Lens": "RF 24-70mm F2.8L IS USM", - "ISO": "100", - "Aperture": "f/2.8", - "Shutter Speed": "1/200s", - "Focal Length": "50mm" -} -Result Full Keys: ['DateTimeOriginal', 'Make', 'Model', 'LensModel', 'ISOSpeedRatings', 'FNumber', 'ExposureTime', 'FocalLength'] -Test PASSED diff --git a/faststack/faststack/faststack.json b/faststack/faststack/faststack.json deleted file mode 100644 index d9c7526..0000000 --- a/faststack/faststack/faststack.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 2, - "last_index": 0, - "entries": {}, - "stacks": [] -} \ No newline at end of file diff --git a/faststack/faststack/imaging/cache.py b/faststack/faststack/imaging/cache.py index 2aa5f0f..75553b3 100644 --- a/faststack/faststack/imaging/cache.py +++ b/faststack/faststack/imaging/cache.py @@ -20,8 +20,6 @@ def __init__( ): super().__init__(maxsize=max_bytes, getsizeof=size_of) self.on_evict = on_evict - self.hits = 0 - self.misses = 0 log.info( f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity." ) @@ -38,7 +36,7 @@ def popitem(self): """Extend popitem to log eviction.""" key, value = super().popitem() log.debug( - f"Evicted item '{key}' to free up space. Cache size: {self.currsize / 1024**2:.2f} MB" + f"Evicted item '{key}'. Cache size after eviction: {self.currsize / 1024**2:.2f} MB" ) if self.on_evict: diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 6b6fa7d..23a545f 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -1,3 +1,4 @@ +import logging import os import shutil import glob @@ -12,6 +13,8 @@ from faststack.models import DecodedImage from PySide6.QtGui import QImage +log = logging.getLogger(__name__) + # Aspect Ratios for cropping INSTAGRAM_RATIOS = { "Freeform": None, @@ -51,7 +54,7 @@ def create_backup_file(original_path: Path) -> Optional[Path]: shutil.copy2(original_path, backup_path) return backup_path except OSError as e: - print(f"Failed to create backup: {e}") + log.error(f"Failed to create backup: {e}") return None # ---------------------------- @@ -229,7 +232,7 @@ def load_image(self, filepath: str, cached_preview: Optional[DecodedImage] = Non return True except Exception as e: - print(f"Error loading image for editing: {e}") + log.error(f"Error loading image for editing: {e}") self.original_image = None self._preview_image = None return False @@ -310,7 +313,7 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I if abs(blacks) > 0.001 or abs(whites) > 0.001: arr = np.array(img, dtype=np.float32) black_point = -blacks * 40 - white_point = 255 + whites * 40 + white_point = 255 - whites * 40 # Prevent division by zero if abs(white_point - black_point) < 0.001: white_point = black_point + 0.001 @@ -517,12 +520,12 @@ def set_edit_param(self, key: str, value: Any) -> bool: final_val = int(rounded_deg) % 360 if abs(val_deg - rounded_deg) > 1.0: - print(f"Warning: 'rotation' received {value}. Rounding to {final_val}. Use 'straighten_angle' for free rotation.") + log.warning(f"'rotation' received {value}. Rounding to {final_val}. Use 'straighten_angle' for free rotation.") self.current_edits[key] = final_val return True except (ValueError, TypeError): - print(f"Error: Invalid value for rotation: {value}") + log.error(f"Invalid value for rotation: {value}") return False if key in self.current_edits and key != 'crop_box': @@ -550,7 +553,7 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: try: original_stat = original_path.stat() except OSError as e: - print(f"Warning: Unable to read timestamps for {original_path}: {e}") + log.warning(f"Unable to read timestamps for {original_path}: {e}") original_stat = None # Use the reusable backup function @@ -582,10 +585,10 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: else: # Fallback for older Pillow: skip writing EXIF if we can't sanitize it # to avoid double-rotation bug. - print("Warning: Pillow too old to sanitize EXIF bytes. Skipping EXIF write to prevent double-rotation.") + log.warning("Pillow too old to sanitize EXIF bytes. Skipping EXIF write to prevent double-rotation.") exif_bytes = None except Exception as e: - print(f"Warning: Failed to sanitize EXIF orientation: {e}") + log.warning(f"Failed to sanitize EXIF orientation: {e}") # Fallback: safer to skip EXIF than write bad orientation exif_bytes = None @@ -603,8 +606,8 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: final_img.save(original_path, **save_kwargs) except Exception as e: exif_was_requested = 'exif' in save_kwargs - print( - f"Warning: Could not save with original format settings" + log.warning( + f"Could not save with original format settings" f"{' (with EXIF)' if exif_was_requested else ''}: {e}" ) @@ -614,23 +617,23 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: retry_kwargs.pop('exif', None) try: final_img.save(original_path, **retry_kwargs) - print( - "Note: Image saved without EXIF metadata; " + log.info( + "Image saved without EXIF metadata; " "EXIF may be corrupted or incompatible with the edited image." ) except Exception as e2: - print(f"Warning: Could not save even without EXIF metadata: {e2}") + log.warning(f"Could not save even without EXIF metadata: {e2}") # Fall through to the final fallback below # Final fallback: let Pillow infer format from suffix / image mode try: final_img.save(original_path) - print( - "Warning: Used final fallback save; image may not use the original " + log.warning( + "Used final fallback save; image may not use the original " "format settings and EXIF metadata is likely lost." ) except Exception as e3: - print(f"Failed to save edited image even with fallback: {e3}") + log.error(f"Failed to save edited image even with fallback: {e3}") # Reraise so the outer except logs and returns None raise @@ -639,7 +642,7 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: return original_path, backup_path except Exception as e: - print(f"Failed to save edited image or backup: {e}") + log.error(f"Failed to save edited image or backup: {e}") return None def _restore_file_times(self, path: Path, original_stat: os.stat_result) -> None: @@ -647,7 +650,7 @@ def _restore_file_times(self, path: Path, original_stat: os.stat_result) -> None try: os.utime(path, (original_stat.st_atime, original_stat.st_mtime)) except OSError as e: - print(f"Warning: Unable to restore timestamps for {path}: {e}") + log.warning(f"Unable to restore timestamps for {path}: {e}") def rotate_image_cw(self): """Decreases the rotation edit parameter by 90° modulo 360 (clockwise).""" diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index 34855f0..9276f35 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -161,11 +161,10 @@ def decode_jpeg_resized( try: from io import BytesIO img = Image.open(BytesIO(jpeg_bytes)) - - if width == 0 or height == 0: - return np.array(img.convert("RGB")) + if width <= 0 or height <= 0: + return np.array(img.convert("RGB")) scale_factor_ratio = min(img.width / width, img.height / height) diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index fde93d0..2ff5e9d 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -11,6 +11,7 @@ import numpy as np from PIL import Image as PILImage, ImageCms +from PySide6.QtCore import QTimer from faststack.models import ImageFile, DecodedImage from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized, TURBO_AVAILABLE @@ -413,6 +414,8 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, h, w, _ = buffer.shape bytes_per_line = w * 3 arr = buffer.reshape(-1).copy() + # Align with non-fallback paths for timing/logging + t_after_copy = time.perf_counter() if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" @@ -444,6 +447,8 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, h, w, _ = buffer.shape bytes_per_line = w * 3 arr = buffer.reshape(-1).copy() + # Align with non-fallback paths for timing/logging + t_after_copy = time.perf_counter() if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" diff --git a/faststack/faststack/io/helicon.py b/faststack/faststack/io/helicon.py index c1539d1..c943a3a 100644 --- a/faststack/faststack/io/helicon.py +++ b/faststack/faststack/io/helicon.py @@ -22,6 +22,8 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: Returns: Tuple of (success: bool, tmp_path: Optional[Path]). Returns (True, tmp_path) if launched successfully, (False, None) otherwise. + On success, the caller is responsible for deleting the returned temporary file + after Helicon Focus completes processing. """ helicon_exe = config.get("helicon", "exe") if not helicon_exe or not isinstance(helicon_exe, str): diff --git a/faststack/faststack/models.py b/faststack/faststack/models.py index ea00c4c..450488d 100644 --- a/faststack/faststack/models.py +++ b/faststack/faststack/models.py @@ -21,6 +21,8 @@ class EntryMetadata: uploaded_date: Optional[str] = None edited: bool = False edited_date: Optional[str] = None + restacked: bool = False + restacked_date: Optional[str] = None @dataclasses.dataclass diff --git a/faststack/faststack/next.prompt b/faststack/faststack/next.prompt index 68f0d42..9f038a7 100644 --- a/faststack/faststack/next.prompt +++ b/faststack/faststack/next.prompt @@ -1 +1 @@ -In the image editor, the whites slider should be reversed- moving it to the left makes the image brighter when it should make it darker. The user should be able to double click on a slider to move it to 0, and also the users should be able to manually enter numbers to move the sliders. Save Edited Image should be Save and Close Editor and Close Editor should be Close Without Saving. Saving an image in the image editor should not bring up a dialog box - instead it should display a breif message in the status bar. In the edits menu the order of the Whites and Shadows sliders should be flipped, so Shadows is next to blacks. Brightness should be moved up under Exposure. Add a Texture slider under Sharpness that does something similar to what the Texture slider does in Photoshop. In the White Balance sliders, spell out the colors instead of abbreviating them with a letter. When in the editor pressing E should close it, but currently doesn't. S should close and (S)ave. After pressing I to bring up the EXIF information, pressing I again should close the EXIF information pane. Add a new flag that is like edited, but is called restacked - it should display in the same way. If the user selects Stack Source Raws from the actions menu and Helicon successfully opens, this flag should be set on an image. Deleting a batch of 78 images skips me forward 78 images - it should keep me on the same image. +In the image editor, the user should be able to double click on a slider to move it to 0, and also the users should be able to manually enter numbers to move the sliders. In the edits menu the order of the Whites and Shadows sliders should be flipped, so Shadows is next to blacks. When in the editor pressing E should close it, but currently doesn't. S should close and (S)ave - it currently says control-s, but control-s does nothing, and it should be just S. Add a new flag that is like edited, but is called restacked - it should display in the same way. If the user selects Stack Source Raws from the actions menu and Helicon successfully opens, this flag should be set on an image. Deleting a batch of 78 images skips me forward 78 images - it should keep me on the same image. diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index d1f4b3d..3707e24 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -656,19 +656,20 @@ Item { // Calculate start aspect ratio (in pixels) if (mainImage.width > 0) { - var box = uiState.currentCropBox - if (box && box.length === 4) { - var boxW = (box[2] - box[0]) / 1000 * mainImage.width - var boxH = (box[3] - box[1]) / 1000 * mainImage.height + var cb = uiState.currentCropBox + if (cb && cb.length === 4) { + var boxW = (cb[2] - cb[0]) / 1000 * mainImage.width + var boxH = (cb[3] - cb[1]) / 1000 * mainImage.height cropStartAspect = boxW / boxH } } // Seed cropBoxStart variables - if (box && box.length === 4) { - cropBoxStartLeft = box[0] - cropBoxStartTop = box[1] + var startBox = uiState.currentCropBox + if (startBox && startBox.length === 4) { + cropBoxStartLeft = startBox[0] + cropBoxStartTop = startBox[1] cropBoxStartRight = box[2] cropBoxStartBottom = box[3] } diff --git a/faststack/faststack/qml/DeleteBatchDialog.qml b/faststack/faststack/qml/DeleteBatchDialog.qml index 38e47f2..fe71f28 100644 --- a/faststack/faststack/qml/DeleteBatchDialog.qml +++ b/faststack/faststack/qml/DeleteBatchDialog.qml @@ -80,7 +80,7 @@ Dialog { } contentItem: Text { text: parent.text - color: "white" + color: deleteBatchDialog.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter font.bold: true diff --git a/faststack/faststack/qml/FilterDialog.qml b/faststack/faststack/qml/FilterDialog.qml index e233291..b1633aa 100644 --- a/faststack/faststack/qml/FilterDialog.qml +++ b/faststack/faststack/qml/FilterDialog.qml @@ -48,7 +48,10 @@ Dialog { verticalAlignment: TextInput.AlignVCenter color: filterDialog.textColor background: Rectangle { - color: filterDialog.backgroundColor + color: Qt.lighter(filterDialog.backgroundColor, 1.2) + border.color: "#505050" + border.width: 1 + radius: 2 } onTextChanged: { @@ -58,7 +61,6 @@ Dialog { Keys.onReturnPressed: filterDialog.accept() Keys.onEnterPressed: filterDialog.accept() } - Label { text: "Leave empty to show all images." font.italic: true @@ -71,17 +73,19 @@ Dialog { onOpened: { // Load current filter string from controller - var current = controller.get_filter_string ? controller.get_filter_string() : "" + var current = controller && controller.get_filter_string ? controller.get_filter_string() : "" filterDialog.filterString = current || "" filterField.text = filterDialog.filterString filterField.forceActiveFocus() filterField.selectAll() // Notify Python that a dialog is open - controller.dialog_opened() - } - + if (controller && controller.dialog_opened) { + controller.dialog_opened() + } onClosed: { // Notify Python that dialog is closed - controller.dialog_closed() - } + if (controller && controller.dialog_closed) { + controller.dialog_closed() + } + } } } diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index e5967ad..4b993b4 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -29,9 +29,6 @@ Window { // Connections need to be outside the visibility check Connections { target: uiState - function onIsHistogramVisibleChanged() { - histogramWindow.visible = uiState.isHistogramVisible - } function onHistogramDataChanged() { if (histogramWindow.visible) { // Since data is bound, the components will update automatically @@ -79,6 +76,10 @@ Window { property int clipCount: 0 property int preClipCount: 0 + onHistogramDataChanged: { + if (canvas && canvas.available) canvas.requestPaint() + } + ColumnLayout { anchors.fill: parent @@ -94,12 +95,19 @@ Window { id: canvas Layout.fillWidth: true Layout.fillHeight: true - + + onAvailableChanged: { + if (available) requestPaint() + } + onPaint: { var ctx = getContext("2d") ctx.clearRect(0, 0, canvas.width, canvas.height) - if (!histogramData || histogramData.length === 0) return + // Handle null or empty data gracefully + if (!histogramData || histogramData.length === undefined || histogramData.length === 0) return + + // console.log(channelName, "len", histogramData ? histogramData.length : "null") // --- Draw Grid --- ctx.strokeStyle = gridLineColor @@ -129,7 +137,7 @@ Window { ctx.moveTo(0, canvas.height) for (i = 0; i < histogramData.length; i++) { - var x = (i / (histogramData.length - 1)) * canvas.width + var x = histogramData.length > 1 ? (i / (histogramData.length - 1)) * canvas.width : canvas.width / 2 var y = canvas.height - (histogramData[i] / maxVal) * canvas.height ctx.lineTo(x, y) } @@ -160,7 +168,7 @@ Window { RowLayout { Layout.alignment: Qt.AlignHCenter spacing: 15 - + Text { text: "Pre-clip: " + preClipCount color: primaryTextColor @@ -190,18 +198,16 @@ Window { onLoaded: { item.channelName = "Red" item.channelColor = "#e15050" - } - Connections { - target: uiState - function onHistogramDataChanged() { - if (redLoader.item && uiState.histogramData) { - redLoader.item.histogramData = uiState.histogramData.r - redLoader.item.clipCount = uiState.histogramData.r_clip - redLoader.item.preClipCount = uiState.histogramData.r_preclip - // Access canvas through item: item.children[0].children[1] is fragile - redLoader.item.children[0].children[1].requestPaint() - } - } + + item.histogramData = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["r"] || []) : [] + }) + item.clipCount = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["r_clip"] || 0) : 0 + }) + item.preClipCount = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["r_preclip"] || 0) : 0 + }) } } @@ -213,17 +219,16 @@ Window { onLoaded: { item.channelName = "Green" item.channelColor = "#50e150" - } - Connections { - target: uiState - function onHistogramDataChanged() { - if (greenLoader.item && uiState.histogramData) { - greenLoader.item.histogramData = uiState.histogramData.g - greenLoader.item.clipCount = uiState.histogramData.g_clip - greenLoader.item.preClipCount = uiState.histogramData.g_preclip - greenLoader.item.children[0].children[1].requestPaint() - } - } + + item.histogramData = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["g"] || []) : [] + }) + item.clipCount = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["g_clip"] || 0) : 0 + }) + item.preClipCount = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["g_preclip"] || 0) : 0 + }) } } @@ -235,17 +240,16 @@ Window { onLoaded: { item.channelName = "Blue" item.channelColor = "#5050e1" - } - Connections { - target: uiState - function onHistogramDataChanged() { - if (blueLoader.item && uiState.histogramData) { - blueLoader.item.histogramData = uiState.histogramData.b - blueLoader.item.clipCount = uiState.histogramData.b_clip - blueLoader.item.preClipCount = uiState.histogramData.b_preclip - blueLoader.item.children[0].children[1].requestPaint() - } - } + + item.histogramData = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["b"] || []) : [] + }) + item.clipCount = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["b_clip"] || 0) : 0 + }) + item.preClipCount = Qt.binding(function() { + return uiState && uiState.histogramData ? (uiState.histogramData["b_preclip"] || 0) : 0 + }) } } } diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml index 60225b8..b554fdc 100644 --- a/faststack/faststack/qml/ImageEditorDialog.qml +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -5,7 +5,7 @@ import QtQuick.Layouts 1.15 import QtQuick.Window 2.15 Window { - id: editDialog + id: imageEditorDialog width: 720 height: 700 title: "Image Editor" @@ -20,10 +20,12 @@ Window { Material.accent: "#4fb360" // When the dialog is closed by the user (e.g. clicking X), update the state - onVisibleChanged: { - if (!visible) { - uiState.isEditorOpen = false - } + // Use onClosing to handle the window close event (e.g. usage of the X button) + // Use onClosing to handle the window close event (e.g. usage of the X button) + onClosing: (close) => { + uiState.isEditorOpen = false + // We accept the close event, letting the window hide/close naturally. + // The binding to 'visible' will update next time uiState.isEditorOpen becomes true. } property int slidersPressedCount: 0 @@ -40,15 +42,26 @@ Window { // Background color: imageEditorDialog.backgroundColor - // Keyboard Shortcuts - Item { - anchors.fill: parent - focus: true - Keys.onPressed: (event) => { - if (event.key === Qt.Key_E || event.key === Qt.Key_Escape) { - uiState.isEditorOpen = false - event.accepted = true - } + Shortcut { + sequence: "E" + context: Qt.WindowShortcut + onActivated: { + uiState.isEditorOpen = false + } + } + Shortcut { + sequence: "Escape" + context: Qt.WindowShortcut + onActivated: { + uiState.isEditorOpen = false + } + } + Shortcut { + sequence: "S" + context: Qt.WindowShortcut + onActivated: { + controller.save_edited_image() + uiState.isEditorOpen = false } } @@ -75,8 +88,8 @@ Window { ListElement { name: "Exposure"; key: "exposure" } ListElement { name: "Brightness"; key: "brightness" } ListElement { name: "Highlights"; key: "highlights" } + ListElement { name: "Whites"; key: "whites" } ListElement { name: "Shadows"; key: "shadows" } - ListElement { name: "Whites"; key: "whites"; reverse: true } ListElement { name: "Blacks"; key: "blacks" } ListElement { name: "Contrast"; key: "contrast" } } @@ -116,7 +129,7 @@ Window { Layout.topMargin: 5 onClicked: { controller.auto_white_balance() - editDialog.updatePulse++ + imageEditorDialog.updatePulse++ } } @@ -126,7 +139,7 @@ Window { Layout.topMargin: 5 onClicked: { controller.auto_levels() - editDialog.updatePulse++ + imageEditorDialog.updatePulse++ } } @@ -154,11 +167,11 @@ Window { Layout.fillWidth: true onClicked: { controller.reset_edit_parameters() - editDialog.updatePulse++ + imageEditorDialog.updatePulse++ } } Button { - text: "Save and Close Editor (Ctrl+S)" + text: "Save and Close (S)" Layout.fillWidth: true onClicked: { controller.save_edited_image() @@ -178,102 +191,70 @@ Window { Component { id: editSlider - ColumnLayout { + RowLayout { Layout.fillWidth: true - spacing: 0 + spacing: 10 property bool isReversed: model.reverse !== undefined ? model.reverse : false - property real displayValue: isReversed ? -slider.value : slider.value - RowLayout { - Layout.fillWidth: true - spacing: 5 - - Text { - text: model.name + ":" - color: imageEditorDialog.textColor - font.pixelSize: 14 - } - - Item { Layout.fillWidth: true } // Spacer - - TextInput { - id: valueInput - text: displayValue.toFixed(0) - color: imageEditorDialog.textColor - font.pixelSize: 14 - selectByMouse: true - validator: IntValidator { bottom: model.min === undefined ? -100 : model.min; top: model.max === undefined ? 100 : model.max } - - onEditingFinished: { - var val = parseInt(text) - if (isNaN(val)) return - - // Clamp value - var min = model.min === undefined ? -100 : model.min - var max = model.max === undefined ? 100 : model.max - if (val < min) val = min - if (val > max) val = max - - var sendValue = isReversed ? -val : val - controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) - editDialog.updatePulse++ // Force slider update - } - } + // Label + Text { + text: model.name + color: imageEditorDialog.textColor + font.pixelSize: 14 + Layout.preferredWidth: 80 + Layout.alignment: Qt.AlignVCenter } + + // Slider Slider { id: slider Layout.fillWidth: true - Layout.minimumHeight: 30 + Layout.alignment: Qt.AlignVCenter from: model.min === undefined ? -100 : model.min to: model.max === undefined ? 100 : model.max stepSize: 1 property real backendValue: { - var val = editDialog.getBackendValue(model.key) * (model.max === undefined ? 100 : model.max) + var val = imageEditorDialog.getBackendValue(model.key) * (model.max === undefined ? 100 : model.max) return isReversed ? -val : val } value: backendValue - - Connections { - target: editDialog - function onUpdatePulseChanged() { - if (!slider.pressed) { - // This forces the visual handle to snap to the backendValue - // even if backendValue hasn't numerically changed (e.g. 0 -> 0) - slider.value = slider.backendValue - } - } - } + + Connections { + target: imageEditorDialog + function onUpdatePulseChanged() { + if (!slider.pressed) { + slider.value = slider.backendValue + } + } + } onMoved: { var sendValue = isReversed ? -value : value controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) } - - // Double click/tap to reset + TapHandler { acceptedButtons: Qt.LeftButton onDoubleTapped: { controller.set_edit_parameter(model.key, 0.0) slider.value = 0.0 - editDialog.updatePulse++ + imageEditorDialog.updatePulse++ } } - + onPressedChanged: { - if (pressed) editDialog.slidersPressedCount++; else editDialog.slidersPressedCount--; + if (pressed) imageEditorDialog.slidersPressedCount++; else imageEditorDialog.slidersPressedCount--; } - - onBackendValueChanged: { - // Check '!pressed' to avoid fighting the user if they are - // currently dragging the slider while an update comes in. - if (!pressed) { - value = backendValue - } - } - + + onBackendValueChanged: { + if (!pressed) { + value = backendValue + } + } + background: Rectangle { x: slider.leftPadding y: slider.topPadding + slider.availableHeight / 2 - height / 2 @@ -284,13 +265,90 @@ Window { } handle: Rectangle { - x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) - y: slider.topPadding + slider.availableHeight / 2 - height / 2 - width: 16 - height: 16 - radius: 8 - color: slider.pressed ? "#4fb360" : "#6fcf7c" - border.color: (uiState && uiState.theme === 0) ? Qt.darker(Material.accent, 1.2) : Qt.lighter(Material.accent, 1.2) + x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) + y: slider.topPadding + slider.availableHeight / 2 - height / 2 + width: 16 + height: 16 + radius: 8 + color: slider.pressed ? "#4fb360" : "#6fcf7c" + border.color: (uiState && uiState.theme === 0) ? Qt.darker(Material.accent, 1.2) : Qt.lighter(Material.accent, 1.2) + } + } + + // SpinBox + // SpinBox + SpinBox { + id: valueInput + from: model.min === undefined ? -100 : model.min + to: model.max === undefined ? 100 : model.max + stepSize: 1 + editable: true + Layout.preferredWidth: 80 // Compact width + Layout.alignment: Qt.AlignVCenter + + value: isReversed ? -slider.value : slider.value + + onValueModified: { + var val = value + var sendValue = isReversed ? -val : val + controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) + imageEditorDialog.updatePulse++ + } + + // Customizations for compact look with small arrows + contentItem: TextInput { + z: 2 + text: valueInput.textFromValue(valueInput.value, valueInput.locale) + font: valueInput.font + color: imageEditorDialog.textColor + selectionColor: "#21be2b" + selectedTextColor: "#ffffff" + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + readOnly: !valueInput.editable + validator: valueInput.validator + inputMethodHints: Qt.ImhFormattedNumbersOnly + } + + up.indicator: Rectangle { + x: valueInput.mirrored ? 0 : parent.width - width + height: parent.height + implicitWidth: 20 // Small width for buttons + implicitHeight: 20 + color: "transparent" + + Text { + text: "+" + font.pixelSize: 14 + anchors.centerIn: parent + color: valueInput.up.pressed ? "#4fb360" : imageEditorDialog.textColor + } + + + } + + down.indicator: Rectangle { + x: valueInput.mirrored ? parent.width - width : 0 + height: parent.height + implicitWidth: 20 // Small width for buttons + implicitHeight: 20 + color: "transparent" + + Text { + text: "-" + font.pixelSize: 14 + anchors.centerIn: parent + color: valueInput.down.pressed ? "#4fb360" : imageEditorDialog.textColor + } + + } + + background: Rectangle { + implicitWidth: 80 + color: "transparent" + border.color: "#555555" + border.width: 1 + radius: 2 } } } diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 78a4d42..ce2214d 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -41,12 +41,6 @@ ApplicationWindow { exifDialog.open() } - Connections { - target: uiState - function onThemeChanged() { - root.isDarkTheme = uiState.theme === 0 - } - } // -------- FLOATING MENU BAR (overlays content) -------- Rectangle { @@ -725,6 +719,11 @@ ApplicationWindow { color: "lightgreen" visible: uiState ? (uiState.imageCount > 0 && uiState.isEdited) : false } + Label { + text: uiState ? ` | Restacked on ${uiState.restackedDate}` : "" + color: "cyan" + visible: uiState ? (uiState.imageCount > 0 && uiState.isRestacked) : false + } Label { text: uiState ? ` | Filter: "${uiState.filterString}"` : "" color: "yellow" diff --git a/faststack/faststack/tests/dummy_images/test.jpg b/faststack/faststack/tests/dummy_images/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..50124629c1f6e0b294c6c121bd6cff999acf5cb9 GIT binary patch literal 7165 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 TW%ufLHnRh}3QV-_X8wNwfhu$O literal 0 HcmV?d00001 diff --git a/faststack/faststack/tests/test_editor.py b/faststack/faststack/tests/test_editor.py index fe426d2..3d8f9bb 100644 --- a/faststack/faststack/tests/test_editor.py +++ b/faststack/faststack/tests/test_editor.py @@ -21,7 +21,7 @@ def test_save_image_preserves_mtime(tmp_path): assert saved is not None saved_path, backup_path = saved - assert saved_path == img_path + assert str(saved_path) == str(img_path) assert backup_path.exists() - assert img_path.stat().st_mtime == pytest.approx(preserved_time, rel=0, abs=1e-6) + assert img_path.stat().st_mtime == pytest.approx(preserved_time, rel=0, abs=2) diff --git a/faststack/faststack/tests/test_editor_rotation.py b/faststack/faststack/tests/test_editor_rotation.py index 36fb76d..2d8a4a6 100644 --- a/faststack/faststack/tests/test_editor_rotation.py +++ b/faststack/faststack/tests/test_editor_rotation.py @@ -81,7 +81,7 @@ def test_rotate_autocrop_rgb_behavior(): # At 45 deg, a square becomes a diamond. The max inscribed rect is w/(sqrt(2)) ~ 0.707*w # 100 * 0.707 = 70. # We expect roughly 70x70 minus inset. - expected_approx = 70.0 + # expected_approx = 70.0 assert 60 < res.width < 80 assert 60 < res.height < 80 @@ -129,7 +129,7 @@ def test_integration_straighten_modes(): editor.current_edits['straighten_angle'] = angle editor.current_edits['crop_box'] = None - res_b = editor._apply_edits(img.copy()) + res_b = editor._apply_edits(img.copy(), for_export=True) # Should define a specific size based on autocrop w_b, h_b = res_b.size @@ -177,11 +177,11 @@ def test_integration_straighten_modes(): editor.current_edits['crop_box'] = (n_left, n_top, n_right, n_bottom) - res_a = editor._apply_edits(img.copy()) + res_a = editor._apply_edits(img.copy(), for_export=True) # Allow for 1-2 pixel differences due to int/round conversions in normalization - assert abs(res_a.width - res_b.width) < 5 - assert abs(res_a.height - res_b.height) < 5 + assert abs(res_a.width - w_b) < 5 + assert abs(res_a.height - h_b) < 5 # Verify both are Green (center pixel) assert res_a.getpixel((res_a.width//2, res_a.height//2)) == (0, 255, 0) diff --git a/faststack/faststack/tests/test_executable_validator.py b/faststack/faststack/tests/test_executable_validator.py index c70e2a8..6aa0c07 100644 --- a/faststack/faststack/tests/test_executable_validator.py +++ b/faststack/faststack/tests/test_executable_validator.py @@ -86,7 +86,7 @@ def test_non_exe_file(): def test_is_executable_windows(): """Test _is_executable on Windows.""" - with patch('os.name', 'nt'): + with patch('os.name', new='nt'): exe_path = MagicMock() exe_path.suffix.lower.return_value = '.exe' assert _is_executable(exe_path) @@ -95,7 +95,6 @@ def test_is_executable_windows(): txt_path.suffix.lower.return_value = '.txt' assert not _is_executable(txt_path) - def test_is_subpath(): """Test _is_subpath logic.""" # This is hard to test without real paths, so we'll test the logic diff --git a/faststack/faststack/tests/test_pairing.py b/faststack/faststack/tests/test_pairing.py index 61304cc..83ef87e 100644 --- a/faststack/faststack/tests/test_pairing.py +++ b/faststack/faststack/tests/test_pairing.py @@ -3,7 +3,7 @@ import os import time from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 0726c96..f5ffd25 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -142,7 +142,7 @@ def __init__(self, app_controller): self._is_editor_open = False self._is_cropping = False self._is_histogram_visible = False - self._histogram_data = None # Will be a dict with 'r', 'g', 'b' arrays + self._histogram_data = {} # Will be a dict with 'r', 'g', 'b' arrays self._brightness = 0.0 self._contrast = 0.0 self._saturation = 0.0 @@ -292,6 +292,18 @@ def editedDate(self): return "" return self.app_controller.get_current_metadata().get("edited_date", "") + @Property(bool, notify=metadataChanged) + def isRestacked(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("restacked", False) + + @Property(str, notify=metadataChanged) + def restackedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("restacked_date", "") + @Property(str, notify=stackSummaryChanged) def stackSummary(self): if not self.app_controller.stacks: @@ -606,8 +618,32 @@ def isHistogramVisible(self, new_value: bool): self.is_histogram_visible_changed.emit(new_value) if new_value: # Update histogram when opened - self.app_controller.update_histogram() - + try: + self.app_controller.update_histogram() + except Exception as e: + log.warning(f"Failed to update histogram: {e}") + + @Slot() + def reset_editor_state(self): + """Resets all editor-related properties to their default values.""" + self.brightness = 0.0 + self.contrast = 0.0 + self.saturation = 0.0 + self.white_balance_by = 0.0 + self.white_balance_mg = 0.0 + self.sharpness = 0.0 + self.rotation = 0 + self.exposure = 0.0 + self.highlights = 0.0 + self.shadows = 0.0 + self.vibrance = 0.0 + self.vignette = 0.0 + self.blacks = 0.0 + self.whites = 0.0 + self.clarity = 0.0 + self.cropRotation = 0.0 + self.currentCropBox = (0, 0, 1000, 1000) + self.currentAspectRatioIndex = 0 @Property('QVariant', notify=histogram_data_changed) def histogramData(self): """Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values.""" @@ -749,7 +785,8 @@ def currentCropBox(self, new_value): self._current_crop_box = new_value self.current_crop_box_changed.emit(new_value) # Sync with ImageEditor - self.app_controller.image_editor.set_crop_box(new_value) + if hasattr(self.app_controller, 'image_editor') and self.app_controller.image_editor: + self.app_controller.image_editor.set_crop_box(new_value) @Property(float, notify=crop_rotation_changed) def cropRotation(self) -> float: diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index a4a4045..367cad7 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -24,6 +24,10 @@ dependencies = [ "cachetools>=5.0,<6.0", "watchdog>=4.0,<5.0", "Pillow>=10.0,<11.0", +] + +[project.optional-dependencies] +dev = [ "pytest>=8.0,<9.0", ] diff --git a/faststack/test_output.txt b/faststack/test_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..ac13cb85de2b747d4f00a6ccef245e91c39c1bba GIT binary patch literal 788 zcmbu7K}*9x5QX1a@IUOun+2+-s$lT*qlFL!on>sZj*0*Av*ScHn)$Qz_S+{R9iT z*Pa_M1ADGaGx8aJNw!JZgVu>|h+pCJAs6+oXKWABjQBM^P0THLHMnc|THS(l$GT8S zbWO(#G_B~+UAo^=Zzp5!!Zj85w=j8))Em*-8qjOK5-GvS@R~kj(%`z7ZacSuH_$-< z!KWR}Mz^2Ra{NObx{mv0LUFy{bD6lUz6aA9v!p5T{cQo(zu5!tImIsY_@i?CEEGfM XO?q=roZrHe5a96t%>OxUhk5%3<+^g* literal 0 HcmV?d00001 From 1de58eaea4544600c721298bba5c1ad86a71ffed Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 1 Jan 2026 15:46:14 -0800 Subject: [PATCH 2/6] modernize editor panel --- faststack/faststack/next.prompt | 2 +- faststack/faststack/qml/ImageEditorDialog.qml | 375 +++++++++++++----- 2 files changed, 267 insertions(+), 110 deletions(-) diff --git a/faststack/faststack/next.prompt b/faststack/faststack/next.prompt index 9f038a7..73f94f7 100644 --- a/faststack/faststack/next.prompt +++ b/faststack/faststack/next.prompt @@ -1 +1 @@ -In the image editor, the user should be able to double click on a slider to move it to 0, and also the users should be able to manually enter numbers to move the sliders. In the edits menu the order of the Whites and Shadows sliders should be flipped, so Shadows is next to blacks. When in the editor pressing E should close it, but currently doesn't. S should close and (S)ave - it currently says control-s, but control-s does nothing, and it should be just S. Add a new flag that is like edited, but is called restacked - it should display in the same way. If the user selects Stack Source Raws from the actions menu and Helicon successfully opens, this flag should be set on an image. Deleting a batch of 78 images skips me forward 78 images - it should keep me on the same image. +In the image editor, the user should be able to double click on a slider to move it to 0 diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml index b554fdc..20f598a 100644 --- a/faststack/faststack/qml/ImageEditorDialog.qml +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -6,26 +6,28 @@ import QtQuick.Window 2.15 Window { id: imageEditorDialog - width: 720 - height: 700 + width: 800 + height: 750 title: "Image Editor" visible: uiState ? uiState.isEditorOpen : false flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint property int updatePulse: 0 - property color backgroundColor: "red" // Placeholder, will be set from Main.qml - property color textColor: "white" // Placeholder, will be set from Main.qml + property color backgroundColor: "#1e1e1e" // Default dark background + property color textColor: "white" // Default text color + // Modern Color Palette + readonly property color accentColor: "#6366f1" // Modern Indigo + readonly property color accentColorHover: "#818cf8" + readonly property color accentColorSubtle: "#306366f1" + readonly property color controlBg: "#10ffffff" + readonly property color controlBorder: "#30ffffff" + readonly property color separatorColor: "#20ffffff" Material.theme: (uiState && uiState.theme === 0) ? Material.Dark : Material.Light - Material.accent: "#4fb360" + Material.accent: accentColor - // When the dialog is closed by the user (e.g. clicking X), update the state - // Use onClosing to handle the window close event (e.g. usage of the X button) - // Use onClosing to handle the window close event (e.g. usage of the X button) onClosing: (close) => { uiState.isEditorOpen = false - // We accept the close event, letting the window hide/close naturally. - // The binding to 'visible' will update next time uiState.isEditorOpen becomes true. } property int slidersPressedCount: 0 @@ -65,24 +67,53 @@ Window { } } + // Component for Section Separator + Component { + id: sectionSeparator + Rectangle { + Layout.fillWidth: true + Layout.topMargin: 20 + Layout.bottomMargin: 5 + height: 1 + color: imageEditorDialog.separatorColor + } + } + + // Component for Section Header + Component { + id: sectionHeader + Label { + text: headerText + font.bold: true + font.pixelSize: 15 + font.letterSpacing: 1.0 + color: imageEditorDialog.accentColorHover + Layout.topMargin: 5 + Layout.bottomMargin: 10 + + property string headerText: "" + } + } + ScrollView { anchors.fill: parent - anchors.margins: 10 + anchors.margins: 20 clip: true contentWidth: availableWidth RowLayout { width: parent.width - spacing: 20 + spacing: 30 - ColumnLayout { // Left Column + // --- LEFT COLUMN --- + ColumnLayout { Layout.fillWidth: true - Layout.preferredWidth: (parent.width - 20) / 2 + Layout.preferredWidth: (parent.width - 30) / 2 Layout.alignment: Qt.AlignTop - spacing: 2 + spacing: 15 // --- Light Group --- - Label { text: "Light"; font.bold: true; color: imageEditorDialog.textColor } + Loader { sourceComponent: sectionHeader; property string headerText: "☀ Light" } ListModel { id: lightModel ListElement { name: "Exposure"; key: "exposure" } @@ -95,8 +126,10 @@ Window { } Repeater { model: lightModel; delegate: editSlider } + Loader { sourceComponent: sectionSeparator } + // --- Detail Group --- - Label { text: "Detail"; font.bold: true; color: imageEditorDialog.textColor; Layout.topMargin: 10 } + Loader { sourceComponent: sectionHeader; property string headerText: "🔍 Detail" } ListModel { id: detailModel ListElement { name: "Clarity"; key: "clarity" } @@ -106,83 +139,146 @@ Window { Repeater { model: detailModel; delegate: editSlider } } - ColumnLayout { // Right Column + // --- RIGHT COLUMN --- + ColumnLayout { Layout.fillWidth: true - Layout.preferredWidth: (parent.width - 20) / 2 + Layout.preferredWidth: (parent.width - 30) / 2 Layout.alignment: Qt.AlignTop - spacing: 2 + spacing: 15 // --- Color Group --- - Label { text: "Color"; font.bold: true; color: imageEditorDialog.textColor } + Loader { sourceComponent: sectionHeader; property string headerText: "🎨 Color" } ListModel { id: colorModel ListElement { name: "Saturation"; key: "saturation"; reverse: false } ListElement { name: "Vibrance"; key: "vibrance"; reverse: false } - ListElement { name: "White Balance (Blue/Yellow)"; key: "white_balance_by"; reverse: false } - ListElement { name: "White Balance (Green/Magenta)"; key: "white_balance_mg"; reverse: false } + ListElement { name: "Temp (Blue/Yel)"; key: "white_balance_by"; reverse: false } + ListElement { name: "Tint (Grn/Mag)"; key: "white_balance_mg"; reverse: false } } Repeater { model: colorModel; delegate: editSlider } - Button { - text: "Auto White Balance" + RowLayout { Layout.fillWidth: true - Layout.topMargin: 5 - onClicked: { - controller.auto_white_balance() - imageEditorDialog.updatePulse++ + spacing: 10 + Button { + text: "Auto WB" + Layout.fillWidth: true + font.pixelSize: 12 + onClicked: { + controller.auto_white_balance() + imageEditorDialog.updatePulse++ + } } - } - - Button { - text: "Auto Levels" - Layout.fillWidth: true - Layout.topMargin: 5 - onClicked: { - controller.auto_levels() - imageEditorDialog.updatePulse++ + Button { + text: "Auto Levels" + Layout.fillWidth: true + font.pixelSize: 12 + onClicked: { + controller.auto_levels() + imageEditorDialog.updatePulse++ + } } } + Loader { sourceComponent: sectionSeparator } + // --- Effects Group --- - Label { text: "Effects"; font.bold: true; color: imageEditorDialog.textColor; Layout.topMargin: 10 } + Loader { sourceComponent: sectionHeader; property string headerText: "✨ Effects" } ListModel { id: effectsModel ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 } } Repeater { model: effectsModel; delegate: editSlider } + Loader { sourceComponent: sectionSeparator } + // --- Transform Group --- - Label { text: "Transform"; font.bold: true; color: imageEditorDialog.textColor; Layout.topMargin: 10 } + Loader { sourceComponent: sectionHeader; property string headerText: "🔄 Transform" } RowLayout { Layout.fillWidth: true - Label { text: "Rotation"; color: imageEditorDialog.textColor } - Button { text: "↶"; onClicked: controller.rotate_image_ccw() } - Button { text: "↷"; onClicked: controller.rotate_image_cw() } + spacing: 15 + Label { + text: "Rotation" + color: imageEditorDialog.textColor + font.pixelSize: 14 + } + Item { Layout.fillWidth: true } // Spacer + Button { + text: "↶ -90°" + onClicked: controller.rotate_image_ccw() + Layout.preferredWidth: 80 + } + Button { + text: "↷ +90°" + onClicked: controller.rotate_image_cw() + Layout.preferredWidth: 80 + } } // --- Action Buttons --- - Item { Layout.fillHeight: true; Layout.minimumHeight: 20 } - Button { - text: "Reset All Edits" + Item { Layout.fillHeight: true; Layout.minimumHeight: 30 } + + RowLayout { Layout.fillWidth: true - onClicked: { - controller.reset_edit_parameters() - imageEditorDialog.updatePulse++ + spacing: 10 + + // Reset (Tertiary) + Button { + text: "Reset" + flat: true + Layout.preferredWidth: 80 + Material.foreground: imageEditorDialog.textColor + onClicked: { + controller.reset_edit_parameters() + imageEditorDialog.updatePulse++ + } + background: Rectangle { + color: parent.pressed ? "#20ffffff" : "transparent" + radius: 4 + border.color: parent.hovered ? "#40ffffff" : "transparent" + } } - } - Button { - text: "Save and Close (S)" - Layout.fillWidth: true - onClicked: { - controller.save_edited_image() - uiState.isEditorOpen = false + + Item { Layout.fillWidth: true } // Spacer + + // Close (Secondary) + Button { + text: "Close" + Layout.preferredWidth: 100 + onClicked: { + uiState.isEditorOpen = false + } + contentItem: Text { + text: parent.text + font: parent.font + opacity: enabled ? 1.0 : 0.3 + color: imageEditorDialog.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#40ffffff" : "#20ffffff" + radius: 4 + border.color: parent.hovered ? "#60ffffff" : "transparent" + } } - } - Button { - text: "Close Without Saving (E)" - Layout.fillWidth: true - onClicked: { - uiState.isEditorOpen = false + + // Save (Primary) + Button { + text: "Save" + Layout.preferredWidth: 100 + highlighted: true + Material.background: imageEditorDialog.accentColor + onClicked: { + controller.save_edited_image() + uiState.isEditorOpen = false + } + background: Rectangle { + color: parent.pressed ? Qt.darker(imageEditorDialog.accentColor, 1.1) : imageEditorDialog.accentColor + radius: 4 + // Subtle shadow simulation + layer.enabled: true + } } } } @@ -193,17 +289,21 @@ Window { id: editSlider RowLayout { Layout.fillWidth: true - spacing: 10 + spacing: 15 property bool isReversed: model.reverse !== undefined ? model.reverse : false + property real minVal: model.min === undefined ? -100 : model.min + property real maxVal: model.max === undefined ? 100 : model.max // Label Text { text: model.name color: imageEditorDialog.textColor - font.pixelSize: 14 - Layout.preferredWidth: 80 + font.pixelSize: 13 + font.weight: Font.Medium + Layout.preferredWidth: 90 Layout.alignment: Qt.AlignVCenter + elide: Text.ElideRight } // Slider @@ -211,12 +311,12 @@ Window { id: slider Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter - from: model.min === undefined ? -100 : model.min - to: model.max === undefined ? 100 : model.max + from: minVal + to: maxVal stepSize: 1 property real backendValue: { - var val = imageEditorDialog.getBackendValue(model.key) * (model.max === undefined ? 100 : model.max) + var val = imageEditorDialog.getBackendValue(model.key) * maxVal return isReversed ? -val : val } @@ -233,7 +333,7 @@ Window { onMoved: { var sendValue = isReversed ? -value : value - controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) + controller.set_edit_parameter(model.key, sendValue / maxVal) } TapHandler { @@ -254,36 +354,77 @@ Window { value = backendValue } } + + // Smooth transition for value changes from backend + Behavior on value { + enabled: !slider.pressed + NumberAnimation { duration: 200; easing.type: Easing.OutQuad } + } - background: Rectangle { + background: Item { x: slider.leftPadding y: slider.topPadding + slider.availableHeight / 2 - height / 2 width: slider.availableWidth - height: 4 - radius: 2 - color: imageEditorDialog.backgroundColor === "#2b2b2b" ? Qt.lighter(imageEditorDialog.backgroundColor, 1.2) : Qt.darker(imageEditorDialog.backgroundColor, 1.2) + height: 6 + + // Track Background + Rectangle { + anchors.fill: parent + radius: 3 + color: imageEditorDialog.controlBg + border.color: imageEditorDialog.controlBorder + border.width: 1 + } + + // Fill Indicator (From 0/Center to Value) + Rectangle { + id: fillRect + property real range: slider.to - slider.from + // Determine anchor point (0 if within range, else min or max) + property real anchorVal: Math.max(slider.from, Math.min(slider.to, 0)) + property real anchorPos: (anchorVal - slider.from) / range + + x: Math.min(slider.visualPosition, anchorPos) * parent.width + width: Math.abs(slider.visualPosition - anchorPos) * parent.width + height: parent.height + radius: 3 + color: imageEditorDialog.accentColor + opacity: 0.6 // Reduced opacity as requested + + Behavior on width { NumberAnimation { duration: 100 } } + Behavior on x { NumberAnimation { duration: 100 } } + } } handle: Rectangle { x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) y: slider.topPadding + slider.availableHeight / 2 - height / 2 - width: 16 - height: 16 - radius: 8 - color: slider.pressed ? "#4fb360" : "#6fcf7c" - border.color: (uiState && uiState.theme === 0) ? Qt.darker(Material.accent, 1.2) : Qt.lighter(Material.accent, 1.2) + width: 12 + height: 12 + radius: 6 + color: slider.pressed ? imageEditorDialog.accentColor : "white" + border.color: slider.pressed ? "white" : imageEditorDialog.accentColor + border.width: 2 + + // Glow/Scale effect on hover + scale: hoverHandler.hovered || slider.pressed ? 1.3 : 1.0 + Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutBack } } + Behavior on color { ColorAnimation { duration: 150 } } + + HoverHandler { + id: hoverHandler + } } } - // SpinBox - // SpinBox + // Refined SpinBox SpinBox { id: valueInput - from: model.min === undefined ? -100 : model.min - to: model.max === undefined ? 100 : model.max + from: minVal + to: maxVal stepSize: 1 editable: true - Layout.preferredWidth: 80 // Compact width + Layout.preferredWidth: 80 Layout.alignment: Qt.AlignVCenter value: isReversed ? -slider.value : slider.value @@ -291,66 +432,82 @@ Window { onValueModified: { var val = value var sendValue = isReversed ? -val : val - controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) + controller.set_edit_parameter(model.key, sendValue / maxVal) imageEditorDialog.updatePulse++ } - // Customizations for compact look with small arrows contentItem: TextInput { z: 2 text: valueInput.textFromValue(valueInput.value, valueInput.locale) - font: valueInput.font + font.pixelSize: 12 + font.family: valueInput.font.family color: imageEditorDialog.textColor - selectionColor: "#21be2b" + selectionColor: imageEditorDialog.accentColor selectedTextColor: "#ffffff" horizontalAlignment: Qt.AlignHCenter verticalAlignment: Qt.AlignVCenter readOnly: !valueInput.editable validator: valueInput.validator inputMethodHints: Qt.ImhFormattedNumbersOnly + + // Highlight on focus + onActiveFocusChanged: { + if(activeFocus) valueInputBackground.border.color = imageEditorDialog.accentColor + else valueInputBackground.border.color = imageEditorDialog.controlBorder + } } - up.indicator: Rectangle { + up.indicator: Item { x: valueInput.mirrored ? 0 : parent.width - width height: parent.height - implicitWidth: 20 // Small width for buttons - implicitHeight: 20 - color: "transparent" + width: 16 // Smaller button - Text { - text: "+" - font.pixelSize: 14 + Rectangle { anchors.centerIn: parent - color: valueInput.up.pressed ? "#4fb360" : imageEditorDialog.textColor + width: 16; height: 16 + radius: 2 + color: valueInput.up.pressed ? imageEditorDialog.accentColor : (valueInput.up.hovered ? Qt.lighter(imageEditorDialog.controlBg, 1.5) : "transparent") + + Text { + text: "+" + font.pixelSize: 12 + anchors.centerIn: parent + color: valueInput.up.pressed ? "white" : imageEditorDialog.textColor + } } - - } - down.indicator: Rectangle { + down.indicator: Item { x: valueInput.mirrored ? parent.width - width : 0 height: parent.height - implicitWidth: 20 // Small width for buttons - implicitHeight: 20 - color: "transparent" + width: 16 // Smaller button - Text { - text: "-" - font.pixelSize: 14 + Rectangle { anchors.centerIn: parent - color: valueInput.down.pressed ? "#4fb360" : imageEditorDialog.textColor + width: 16; height: 16 + radius: 2 + color: valueInput.down.pressed ? imageEditorDialog.accentColor : (valueInput.down.hovered ? Qt.lighter(imageEditorDialog.controlBg, 1.5) : "transparent") + + Text { + text: "-" + font.pixelSize: 12 + anchors.centerIn: parent + color: valueInput.down.pressed ? "white" : imageEditorDialog.textColor + } } - } background: Rectangle { + id: valueInputBackground implicitWidth: 80 color: "transparent" - border.color: "#555555" + border.color: imageEditorDialog.controlBorder border.width: 1 - radius: 2 + radius: 4 + + Behavior on border.color { ColorAnimation { duration: 150 } } } } } } -} +} \ No newline at end of file From 3a9e4c5c990ad3b7fc5c847ed7cf37c3ab49f8b0 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 1 Jan 2026 15:53:26 -0800 Subject: [PATCH 3/6] Fix E hotkey --- faststack/faststack/qml/HistogramWindow.qml | 4 +-- faststack/faststack/qml/ImageEditorDialog.qml | 7 ---- faststack/faststack/qml/Main.qml | 34 ++++++++++--------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index 4b993b4..9ba52dc 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -8,8 +8,8 @@ Window { title: "RGB Histogram" width: 750 height: 450 - minimumWidth: 500 - minimumHeight: 350 + minimumWidth: 250 + minimumHeight: 150 visible: uiState ? uiState.isHistogramVisible : false FocusScope { diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml index 20f598a..0608e64 100644 --- a/faststack/faststack/qml/ImageEditorDialog.qml +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -44,13 +44,6 @@ Window { // Background color: imageEditorDialog.backgroundColor - Shortcut { - sequence: "E" - context: Qt.WindowShortcut - onActivated: { - uiState.isEditorOpen = false - } - } Shortcut { sequence: "Escape" context: Qt.WindowShortcut diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index ce2214d..73f86fe 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -631,6 +631,23 @@ ApplicationWindow { property int footerHeight: 60 + Shortcut { + sequence: "E" + context: Qt.ApplicationShortcut + onActivated: { + if (!uiState) return + + if (uiState.isEditorOpen) { + uiState.isEditorOpen = false + } else { + uiState.isEditorOpen = true + if (controller) { + controller.load_image_for_editing() + } + } + } + } + // -------- MAIN VIEW -------- Item { id: contentArea @@ -649,23 +666,8 @@ ApplicationWindow { return } - // Toggle Image Editor with 'E' key - // If editor is open, close it without saving. Otherwise open it. - if (event.key === Qt.Key_E && !event.isAutoRepeat) { - if (uiState.isEditorOpen) { - // Close editor without saving - uiState.isEditorOpen = false - } else { - // Open editor - uiState.isEditorOpen = true - if (controller) { - controller.load_image_for_editing() - } - } - event.accepted = true - } // Global Key for saving edited image (Ctrl+S) when editor is open - else if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { + if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { if (uiState.isEditorOpen) { controller.save_edited_image() event.accepted = true From 14385f815d6866d8f5fe378dd5aab0be04aebef3 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 1 Jan 2026 15:55:37 -0800 Subject: [PATCH 4/6] Histogram can now be smaller --- faststack/faststack/qml/HistogramWindow.qml | 26 +++++++++++---------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index 9ba52dc..a07afc7 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -8,8 +8,8 @@ Window { title: "RGB Histogram" width: 750 height: 450 - minimumWidth: 250 - minimumHeight: 150 + minimumWidth: 100 + minimumHeight: 50 visible: uiState ? uiState.isHistogramVisible : false FocusScope { @@ -82,13 +82,15 @@ Window { ColumnLayout { anchors.fill: parent + spacing: 2 Text { text: channelName color: channelColor font.bold: true - font.pixelSize: 14 + font.pixelSize: Math.max(10, Math.min(14, histogramWindow.height / 30)) Layout.alignment: Qt.AlignHCenter + visible: histogramWindow.height > 100 } Canvas { @@ -107,8 +109,6 @@ Window { // Handle null or empty data gracefully if (!histogramData || histogramData.length === undefined || histogramData.length === 0) return - // console.log(channelName, "len", histogramData ? histogramData.length : "null") - // --- Draw Grid --- ctx.strokeStyle = gridLineColor ctx.lineWidth = 1 @@ -167,18 +167,20 @@ Window { RowLayout { Layout.alignment: Qt.AlignHCenter - spacing: 15 + spacing: 5 + visible: histogramWindow.height > 80 Text { - text: "Pre-clip: " + preClipCount + text: "P:" + preClipCount color: primaryTextColor - font.pixelSize: 11 + font.pixelSize: Math.max(8, Math.min(11, histogramWindow.height / 40)) + visible: histogramWindow.width > 400 } Text { - text: "Clipped: " + clipCount + text: (histogramWindow.width > 400 ? "Clipped: " : "C:") + clipCount color: clipCount > 0 ? "red" : primaryTextColor font.bold: clipCount > 0 - font.pixelSize: 11 + font.pixelSize: Math.max(8, Math.min(11, histogramWindow.height / 40)) } } } @@ -187,8 +189,8 @@ Window { RowLayout { anchors.fill: parent - anchors.margins: 15 - spacing: 15 + anchors.margins: histogramWindow.width > 200 ? 15 : 2 + spacing: histogramWindow.width > 200 ? 15 : 2 Loader { id: redLoader From d5b0bc255f20994d2f01040575b51c30cfd605b7 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 1 Jan 2026 20:23:04 -0800 Subject: [PATCH 5/6] Histogram fixes, crop box fixes --- faststack/faststack/app.py | 36 ++- faststack/faststack/next.prompt | 1 - faststack/faststack/qml/Components.qml | 6 +- faststack/faststack/qml/HistogramWindow.qml | 224 +++--------------- faststack/faststack/qml/ImageEditorDialog.qml | 118 ++++++++- .../faststack/qml/SingleChannelHistogram.qml | 132 +++++++++++ 6 files changed, 309 insertions(+), 208 deletions(-) delete mode 100644 faststack/faststack/next.prompt create mode 100644 faststack/faststack/qml/SingleChannelHistogram.qml diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index fac744a..a9972b6 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -177,6 +177,13 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.resize_timer.timeout.connect(self._handle_resize) self.pending_width = None self.pending_height = None + + # Histogram Throttle Timer + self.histogram_timer = QTimer(self) + self.histogram_timer.setSingleShot(True) + self.histogram_timer.setInterval(50) # 50ms throttle (max 20fps) + self.histogram_timer.timeout.connect(self._perform_update_histogram) + self._pending_histogram_args = None # Track if any dialog is open to disable keybindings self._dialog_open = False @@ -2546,7 +2553,7 @@ def toggle_histogram(self): @Slot() @Slot(float, float, float, float) # zoom, panX, panY, imageScale def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = 0.0, image_scale: float = 1.0): - """Update histogram data from current image. + """Throttled request to update histogram. Updates continuously but capped at interval. Args: zoom: Zoom scale factor (1.0 = no zoom) @@ -2554,8 +2561,25 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = pan_y: Pan offset in Y direction (in image coordinates) image_scale: Scale factor of displayed image vs original """ - # Return immediately if histogram is not visible - if not self.ui_state.isHistogramVisible: + # Early guard: don't even schedule if nothing is showing the histogram + if not (self.ui_state.isHistogramVisible or self.ui_state.isEditorOpen): + self._pending_histogram_args = None + return + + self._pending_histogram_args = (zoom, pan_x, pan_y, image_scale) + if not self.histogram_timer.isActive(): + self.histogram_timer.start() + + def _perform_update_histogram(self): + """Actual histogram computation logic (called by timer).""" + if not self._pending_histogram_args: + return + + zoom, pan_x, pan_y, image_scale = self._pending_histogram_args + self._pending_histogram_args = None + + # Return immediately if neither histogram window nor editor is visible + if not (self.ui_state.isHistogramVisible or self.ui_state.isEditorOpen): return if not self.image_files or self.current_index >= len(self.image_files): @@ -2666,6 +2690,10 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = except Exception as e: log.exception("Failed to compute histogram: %s", e) self.update_status_message(f"Histogram error: {e}") + + # Check if new requests arrived while we were computing + if self._pending_histogram_args is not None: + self.histogram_timer.start() @Slot() def execute_crop(self): @@ -2916,6 +2944,8 @@ def execute_crop(self): # This is the single source of truth since set_straighten_angle updates it live. current_rotation = float(self.image_editor.current_edits.get("straighten_angle", 0.0)) + crop_box_raw = self.ui_state.currentCropBox + # Ensure ImageEditor has the latest crop box (it should be synced via UIState, but good to be safe) if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: # Try to convert if it came as list diff --git a/faststack/faststack/next.prompt b/faststack/faststack/next.prompt deleted file mode 100644 index 73f94f7..0000000 --- a/faststack/faststack/next.prompt +++ /dev/null @@ -1 +0,0 @@ -In the image editor, the user should be able to double click on a slider to move it to 0 diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 3707e24..bea1295 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -754,8 +754,10 @@ Item { rotationThrottleTimer.start() } } - - + // Return early to prevent overwriting crop box during rotation + return + } else { + // Handle move/resize (edge dragging) var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) // Clamp to image bounds and convert to 0-1000 range diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index a07afc7..944d11d 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -20,30 +20,15 @@ Window { Keys.onPressed: function(event) { if (event.key === Qt.Key_H && controller) { controller.toggle_histogram() - event.accepted = true // Only accept if H is pressed + event.accepted = true } - // For other keys, event.accepted remains false, allowing propagation } } - // Connections need to be outside the visibility check Connections { target: uiState - function onHistogramDataChanged() { - if (histogramWindow.visible) { - // Since data is bound, the components will update automatically - } - } function onCurrentImageSourceChanged() { if (histogramWindow.visible && controller) { - // Get zoom/pan info from main image view - var zoom = 1.0 - var panX = 0.0 - var panY = 0.0 - var imageScale = 1.0 - - // Try to get zoom/pan from Components (if accessible) - // For now, just call without params - Components will handle it controller.update_histogram() } } @@ -57,7 +42,6 @@ Window { } // --- Injected Properties --- - // These are set by Main.qml to decouple the component from global state property color windowBackgroundColor: "#f4f4f4" property color primaryTextColor: "#222222" property color gridLineColor: "#dcdcdc" @@ -65,194 +49,54 @@ Window { color: windowBackgroundColor - - Component { - id: singleChannelHistogram - - Item { - property string channelName: "Channel" - property color channelColor: "white" - property var histogramData: [] - property int clipCount: 0 - property int preClipCount: 0 - - onHistogramDataChanged: { - if (canvas && canvas.available) canvas.requestPaint() - } - - ColumnLayout { - anchors.fill: parent - spacing: 2 - - Text { - text: channelName - color: channelColor - font.bold: true - font.pixelSize: Math.max(10, Math.min(14, histogramWindow.height / 30)) - Layout.alignment: Qt.AlignHCenter - visible: histogramWindow.height > 100 - } - - Canvas { - id: canvas - Layout.fillWidth: true - Layout.fillHeight: true - - onAvailableChanged: { - if (available) requestPaint() - } - - onPaint: { - var ctx = getContext("2d") - ctx.clearRect(0, 0, canvas.width, canvas.height) - - // Handle null or empty data gracefully - if (!histogramData || histogramData.length === undefined || histogramData.length === 0) return - - // --- Draw Grid --- - ctx.strokeStyle = gridLineColor - ctx.lineWidth = 1 - for (var i = 1; i < 4; i++) { - var y = i * canvas.height / 4 - ctx.beginPath() - ctx.moveTo(0, y) - ctx.lineTo(canvas.width, y) - ctx.stroke() - } - - // --- Draw Danger Zone --- - var dangerZoneStart = (250 / 255) * canvas.width - ctx.fillStyle = dangerColor - ctx.fillRect(dangerZoneStart, 0, canvas.width - dangerZoneStart, canvas.height) - - // --- Prepare data for drawing --- - var maxVal = 0 - for (i = 0; i < histogramData.length; i++) { - maxVal = Math.max(maxVal, histogramData[i]) - } - if (maxVal === 0) return - - // --- Draw Histogram Path --- - ctx.beginPath() - ctx.moveTo(0, canvas.height) - - for (i = 0; i < histogramData.length; i++) { - var x = histogramData.length > 1 ? (i / (histogramData.length - 1)) * canvas.width : canvas.width / 2 - var y = canvas.height - (histogramData[i] / maxVal) * canvas.height - ctx.lineTo(x, y) - } - - ctx.lineTo(canvas.width, canvas.height) - ctx.closePath() - - // Create gradient fill - var gradient = ctx.createLinearGradient(0, 0, 0, canvas.height) - var transparentColor = Qt.color(channelColor) - transparentColor.a = 0.0 - var semiTransparentColor = Qt.color(channelColor) - semiTransparentColor.a = 0.4 - - gradient.addColorStop(0, semiTransparentColor) - gradient.addColorStop(1, transparentColor) - - ctx.fillStyle = gradient - ctx.fill() - - // Draw outline - ctx.strokeStyle = channelColor - ctx.lineWidth = 1.5 - ctx.stroke() - } - } - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: 5 - visible: histogramWindow.height > 80 - - Text { - text: "P:" + preClipCount - color: primaryTextColor - font.pixelSize: Math.max(8, Math.min(11, histogramWindow.height / 40)) - visible: histogramWindow.width > 400 - } - Text { - text: (histogramWindow.width > 400 ? "Clipped: " : "C:") + clipCount - color: clipCount > 0 ? "red" : primaryTextColor - font.bold: clipCount > 0 - font.pixelSize: Math.max(8, Math.min(11, histogramWindow.height / 40)) - } - } - } - } - } - RowLayout { anchors.fill: parent anchors.margins: histogramWindow.width > 200 ? 15 : 2 spacing: histogramWindow.width > 200 ? 15 : 2 - Loader { - id: redLoader + SingleChannelHistogram { Layout.fillWidth: true Layout.fillHeight: true - sourceComponent: singleChannelHistogram - onLoaded: { - item.channelName = "Red" - item.channelColor = "#e15050" - - item.histogramData = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["r"] || []) : [] - }) - item.clipCount = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["r_clip"] || 0) : 0 - }) - item.preClipCount = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["r_preclip"] || 0) : 0 - }) - } + + channelName: "Red" + channelColor: "#e15050" + gridLineColor: histogramWindow.gridLineColor + dangerColor: histogramWindow.dangerColor + textColor: histogramWindow.primaryTextColor + + histogramData: uiState && uiState.histogramData ? (uiState.histogramData["r"] || []) : [] + clipCount: uiState && uiState.histogramData ? (uiState.histogramData["r_clip"] || 0) : 0 + preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["r_preclip"] || 0) : 0 } - Loader { - id: greenLoader + SingleChannelHistogram { Layout.fillWidth: true Layout.fillHeight: true - sourceComponent: singleChannelHistogram - onLoaded: { - item.channelName = "Green" - item.channelColor = "#50e150" - - item.histogramData = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["g"] || []) : [] - }) - item.clipCount = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["g_clip"] || 0) : 0 - }) - item.preClipCount = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["g_preclip"] || 0) : 0 - }) - } + + channelName: "Green" + channelColor: "#50e150" + gridLineColor: histogramWindow.gridLineColor + dangerColor: histogramWindow.dangerColor + textColor: histogramWindow.primaryTextColor + + histogramData: uiState && uiState.histogramData ? (uiState.histogramData["g"] || []) : [] + clipCount: uiState && uiState.histogramData ? (uiState.histogramData["g_clip"] || 0) : 0 + preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["g_preclip"] || 0) : 0 } - Loader { - id: blueLoader + SingleChannelHistogram { Layout.fillWidth: true Layout.fillHeight: true - sourceComponent: singleChannelHistogram - onLoaded: { - item.channelName = "Blue" - item.channelColor = "#5050e1" - - item.histogramData = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["b"] || []) : [] - }) - item.clipCount = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["b_clip"] || 0) : 0 - }) - item.preClipCount = Qt.binding(function() { - return uiState && uiState.histogramData ? (uiState.histogramData["b_preclip"] || 0) : 0 - }) - } + + channelName: "Blue" + channelColor: "#5050e1" + gridLineColor: histogramWindow.gridLineColor + dangerColor: histogramWindow.dangerColor + textColor: histogramWindow.primaryTextColor + + histogramData: uiState && uiState.histogramData ? (uiState.histogramData["b"] || []) : [] + clipCount: uiState && uiState.histogramData ? (uiState.histogramData["b_clip"] || 0) : 0 + preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["b_preclip"] || 0) : 0 } } } diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml index 0608e64..87c0c7e 100644 --- a/faststack/faststack/qml/ImageEditorDialog.qml +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -30,6 +30,19 @@ Window { uiState.isEditorOpen = false } + onVisibleChanged: { + if (visible) { + if (controller) controller.update_histogram() + } + } + + // Auto-update histogram when pulse changes (buttons, double-taps, spinbox) + onUpdatePulseChanged: { + if (visible && controller) { + controller.update_histogram() + } + } + property int slidersPressedCount: 0 onSlidersPressedCountChanged: { uiState.setAnySliderPressed(slidersPressedCount > 0) @@ -90,7 +103,8 @@ Window { ScrollView { anchors.fill: parent - anchors.margins: 20 + anchors.margins: 10 + anchors.topMargin: 5 clip: true contentWidth: availableWidth @@ -106,7 +120,11 @@ Window { spacing: 15 // --- Light Group --- - Loader { sourceComponent: sectionHeader; property string headerText: "☀ Light" } + Loader { + sourceComponent: sectionHeader + property string headerText: "☀ Light" + Layout.topMargin: 0 // Remove top margin for the very first item + } ListModel { id: lightModel ListElement { name: "Exposure"; key: "exposure" } @@ -130,6 +148,62 @@ Window { ListElement { name: "Sharpness"; key: "sharpness" } } Repeater { model: detailModel; delegate: editSlider } + + // --- Histogram Group --- + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 120 + Layout.topMargin: 5 + spacing: 5 + + SingleChannelHistogram { + Layout.fillWidth: true + Layout.fillHeight: true + + channelName: "R" + channelColor: "#e15050" + gridLineColor: imageEditorDialog.controlBorder + dangerColor: "#40ff0000" + textColor: imageEditorDialog.textColor + minimal: true + + histogramData: uiState && uiState.histogramData ? (uiState.histogramData["r"] || []) : [] + clipCount: uiState && uiState.histogramData ? (uiState.histogramData["r_clip"] || 0) : 0 + preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["r_preclip"] || 0) : 0 + } + + SingleChannelHistogram { + Layout.fillWidth: true + Layout.fillHeight: true + + channelName: "G" + channelColor: "#50e150" + gridLineColor: imageEditorDialog.controlBorder + dangerColor: "#40ff0000" + textColor: imageEditorDialog.textColor + minimal: true + + histogramData: uiState && uiState.histogramData ? (uiState.histogramData["g"] || []) : [] + clipCount: uiState && uiState.histogramData ? (uiState.histogramData["g_clip"] || 0) : 0 + preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["g_preclip"] || 0) : 0 + } + + SingleChannelHistogram { + Layout.fillWidth: true + Layout.fillHeight: true + + channelName: "B" + channelColor: "#5050e1" + gridLineColor: imageEditorDialog.controlBorder + dangerColor: "#40ff0000" + textColor: imageEditorDialog.textColor + minimal: true + + histogramData: uiState && uiState.histogramData ? (uiState.histogramData["b"] || []) : [] + clipCount: uiState && uiState.histogramData ? (uiState.histogramData["b_clip"] || 0) : 0 + preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["b_preclip"] || 0) : 0 + } + } } // --- RIGHT COLUMN --- @@ -140,7 +214,11 @@ Window { spacing: 15 // --- Color Group --- - Loader { sourceComponent: sectionHeader; property string headerText: "🎨 Color" } + Loader { + sourceComponent: sectionHeader + property string headerText: "🎨 Color" + Layout.topMargin: 0 // Remove top margin for the very first item + } ListModel { id: colorModel ListElement { name: "Saturation"; key: "saturation"; reverse: false } @@ -327,19 +405,35 @@ Window { onMoved: { var sendValue = isReversed ? -value : value controller.set_edit_parameter(model.key, sendValue / maxVal) + // Trigger live histogram update (throttled by Python backend) + if (controller) controller.update_histogram() } - TapHandler { - acceptedButtons: Qt.LeftButton - onDoubleTapped: { - controller.set_edit_parameter(model.key, 0.0) - slider.value = 0.0 - imageEditorDialog.updatePulse++ - } - } + property double lastPressTime: 0 + property double lastPressValue: 0 onPressedChanged: { - if (pressed) imageEditorDialog.slidersPressedCount++; else imageEditorDialog.slidersPressedCount--; + if (pressed) { + var now = Date.now() + var range = slider.to - slider.from + var diff = Math.abs(value - lastPressValue) + + // Double click detection: <500ms time diff AND <5% value diff + // This prevents false positives when dragging quickly + if (now - lastPressTime < 500 && diff < (range * 0.05)) { + controller.set_edit_parameter(model.key, 0.0) + imageEditorDialog.updatePulse++ + value = 0.0 + } + lastPressTime = now + lastPressValue = value + + imageEditorDialog.slidersPressedCount++ + } else { + imageEditorDialog.slidersPressedCount-- + // Update histogram on release + if (controller) controller.update_histogram() + } } onBackendValueChanged: { diff --git a/faststack/faststack/qml/SingleChannelHistogram.qml b/faststack/faststack/qml/SingleChannelHistogram.qml new file mode 100644 index 0000000..6d4683a --- /dev/null +++ b/faststack/faststack/qml/SingleChannelHistogram.qml @@ -0,0 +1,132 @@ +import QtQuick +import QtQuick.Layouts 1.15 + +Item { + id: root + property string channelName: "Channel" + property color channelColor: "white" + property var histogramData: [] + property int clipCount: 0 + property int preClipCount: 0 + property color gridLineColor: "#50ffffff" // Default semi-transparent white + property color dangerColor: Qt.rgba(1, 0, 0, 0.25) + property color textColor: "white" + + // Allow minimal mode (hide text) + property bool minimal: false + + onHistogramDataChanged: { + if (canvas && canvas.available) canvas.requestPaint() + } + + ColumnLayout { + anchors.fill: parent + spacing: 2 + + Text { + text: root.channelName + color: root.channelColor + font.bold: true + // Dynamic font size based on height, but capped + font.pixelSize: Math.max(10, Math.min(14, root.height / 10)) + Layout.alignment: Qt.AlignHCenter + visible: !root.minimal && root.height > 100 + } + + Canvas { + id: canvas + Layout.fillWidth: true + Layout.fillHeight: true + + onAvailableChanged: { + if (available) requestPaint() + } + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // Handle null or empty data gracefully + if (!root.histogramData || root.histogramData.length === undefined || root.histogramData.length === 0) return + + // --- Draw Grid --- + ctx.strokeStyle = root.gridLineColor + ctx.lineWidth = 1 + for (var i = 1; i < 4; i++) { + var y = i * canvas.height / 4 + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(canvas.width, y) + ctx.stroke() + } + + // --- Draw Danger Zone --- + // The rightmost ~2% (250-255) + var dangerZoneStart = (250 / 255) * canvas.width + ctx.fillStyle = root.dangerColor + ctx.fillRect(dangerZoneStart, 0, canvas.width - dangerZoneStart, canvas.height) + + // --- Prepare data for drawing --- + var maxVal = 0 + for (i = 0; i < root.histogramData.length; i++) { + maxVal = Math.max(maxVal, root.histogramData[i]) + } + if (maxVal === 0) return + + // --- Draw Histogram Path --- + ctx.beginPath() + ctx.moveTo(0, canvas.height) + + var len = root.histogramData.length + for (i = 0; i < len; i++) { + var x = len > 1 ? (i / (len - 1)) * canvas.width : canvas.width / 2 + var y = canvas.height - (root.histogramData[i] / maxVal) * canvas.height + ctx.lineTo(x, y) + } + + ctx.lineTo(canvas.width, canvas.height) + ctx.closePath() + + // Create gradient fill + var gradient = ctx.createLinearGradient(0, 0, 0, canvas.height) + var transparentColor = Qt.color(root.channelColor) + transparentColor.a = 0.0 + var semiTransparentColor = Qt.color(root.channelColor) + semiTransparentColor.a = 0.4 + + gradient.addColorStop(0, semiTransparentColor) + gradient.addColorStop(1, transparentColor) + + ctx.fillStyle = gradient + ctx.fill() + + // Draw outline + ctx.strokeStyle = root.channelColor + ctx.lineWidth = 1.5 + ctx.stroke() + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 5 + visible: !root.minimal && root.height > 80 + + Text { + text: "P:" + root.preClipCount + color: root.textColor + font.pixelSize: Math.max(8, Math.min(11, root.height / 15)) + visible: root.width > 120 + } + Text { + text: (root.width > 120 ? "Clipped: " : "C:") + root.clipCount + color: root.clipCount > 0 ? "red" : root.textColor + font.bold: root.clipCount > 0 + font.pixelSize: Math.max(8, Math.min(11, root.height / 15)) + } + } + } +} From 6d111fff963a00db268a75c98fdd835b1d6826bc Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 1 Jan 2026 20:43:49 -0800 Subject: [PATCH 6/6] crop/rotate box fixes --- faststack/faststack/qml/Components.qml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index bea1295..e0244e7 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -276,15 +276,14 @@ Item { rotation: mainMouseArea.cropRotation - // Crop overlay - moved back to mainImage for Visual Orbit (Rotate Together) - // Coordinates are now Source Space, and backend handles conversion. + // Crop overlay - anchored to mainImage to rotate with it Item { id: cropOverlay property var cropBox: uiState ? uiState.currentCropBox : [0, 0, 1000, 1000] property bool hasActiveCrop: cropBox && cropBox.length === 4 && !(cropBox[0]===0 && cropBox[1]===0 && cropBox[2]===1000 && cropBox[3]===1000) visible: uiState && uiState.isCropping && (hasActiveCrop || mainMouseArea.isRotating) - anchors.fill: parent // Fills mainImage (Source Space) + anchors.fill: parent // Fills mainImage z: 100 onCropBoxChanged: { if (parent.source) updateCropRect() } @@ -499,6 +498,8 @@ Item { } + + } } @@ -670,8 +671,8 @@ Item { if (startBox && startBox.length === 4) { cropBoxStartLeft = startBox[0] cropBoxStartTop = startBox[1] - cropBoxStartRight = box[2] - cropBoxStartBottom = box[3] + cropBoxStartRight = startBox[2] + cropBoxStartBottom = startBox[3] } isCropDragging = true @@ -722,7 +723,7 @@ Item { } } // Legacy getCropRect removed - using Image Space hit testing instead. - // mapToImageCoordinates now maps directly to mainImage + // mapToImageCoordinates maps directly to mainImage function mapToImageCoordinates(screenPoint) { var p = mainMouseArea.mapToItem(mainImage, screenPoint.x, screenPoint.y) return {x: p.x / mainImage.width, y: p.y / mainImage.height} @@ -748,7 +749,7 @@ Item { // Update rotation in backend live (throttled) if (controller) { pendingRotation = cropRotation - pendingAspect = cropStartAspect + pendingAspect = -1 if (!rotationThrottleTimer.running) { rotationThrottleTimer.start()