diff --git a/faststack/app.py b/faststack/app.py index a659e35..72f9316 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -11,9 +11,14 @@ from typing import Optional, List, Dict, Any, Tuple from datetime import date import os +import shutil # Must set before importing PySide6 os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" +# Type Aliases for readability +DeletePair = Tuple[Optional[Path], Optional[Path]] # (src_path, recycle_bin_path) +DeleteRecord = Tuple[DeletePair, DeletePair] # (jpg_pair, raw_pair) + import concurrent.futures import threading import subprocess @@ -87,6 +92,7 @@ class AppController(QObject): is_zoomed_changed = Signal(bool) # Signal for zoom state changes histogramReady = Signal(object) # Signal for off-thread histogram result previewReady = Signal(object) # Signal for off-thread preview result + dialogStateChanged = Signal(bool) # Signal for dialog open/close state class ProgressReporter(QObject): progress_updated = Signal(int) @@ -139,6 +145,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.watcher = Watcher(self.image_dir, self.refresh_image_list) self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) self.image_editor = ImageEditor() # Initialize the editor + self._dialog_open_count = 0 # Track nested dialogs # -- Caching & Prefetching -- cache_size_gb = config.getfloat('core', 'cache_size_gb', 1.5) @@ -191,9 +198,10 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: # -- Delete/Undo State -- self.recycle_bin_dir = self.image_dir / "image recycle bin" - self.delete_history: List[Tuple[Path, Optional[Path]]] = [] # [(jpg_path, raw_path), ...] + self.delete_history: List[DeleteRecord] = [] # [((jpg_src, jpg_bin), (raw_src, raw_bin)), ...] # Track all undoable actions with timestamps - self.undo_history: List[Tuple[str, Any, float]] = [] # (action_type, action_data, timestamp) + # [(action_type, action_data, timestamp)] + self.undo_history: List[Tuple[str, Any, float]] = [] self.resize_timer = QTimer() self.resize_timer.setSingleShot(True) @@ -712,14 +720,21 @@ def show_exif_dialog(self): @Slot() def dialog_opened(self): """Called when any dialog opens to disable global keybindings.""" - self._dialog_open = True - log.debug("Dialog opened, disabling global keybindings") + self._dialog_open_count += 1 + if self._dialog_open_count == 1: + self._dialog_open = True + self.dialogStateChanged.emit(True) + log.debug("Dialog opened (count=1), disabling global keybindings") @Slot() def dialog_closed(self): """Called when any dialog closes to re-enable global keybindings.""" - self._dialog_open = False - log.debug("Dialog closed, re-enabling global keybindings") + prev = self._dialog_open_count + self._dialog_open_count = max(0, self._dialog_open_count - 1) + if prev > 0 and self._dialog_open_count == 0: + self._dialog_open = False + self.dialogStateChanged.emit(False) + log.debug("Dialog closed (count=0), re-enabling global keybindings") def toggle_grid_view(self): log.warning("Grid view not implemented yet.") @@ -1298,6 +1313,13 @@ def set_photoshop_path(self, path): config.set('photoshop', 'exe', path) config.save() + def get_rawtherapee_path(self): + return config.get('rawtherapee', 'exe') + + def set_rawtherapee_path(self, path): + config.set('rawtherapee', 'exe', path) + config.save() + def open_file_dialog(self): dialog = QFileDialog() dialog.setFileMode(QFileDialog.FileMode.ExistingFile) @@ -1818,6 +1840,39 @@ def delete_current_image(self): # Single image deletion - proceed normally self._delete_single_image(self.current_index) + def _move_to_recycle(self, src: Path) -> Optional[Path]: + """Moves a file to the recycle bin safely, handling collisions and cross-device moves.""" + if not src.exists() or not src.is_file(): + return None + + # Ensure recycle bin exists + try: + self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + log.error("Failed to create recycle bin: %s", e) + return None + + dest = self.recycle_bin_dir / src.name + + # Handle collisions with timestamp loop + if dest.exists(): + import time + timestamp = int(time.time()) + base_name = f"{src.stem}.{timestamp}" + dest = self.recycle_bin_dir / f"{base_name}{src.suffix}" + counter = 1 + while dest.exists(): + dest = self.recycle_bin_dir / f"{base_name}_{counter}{src.suffix}" + counter += 1 + + try: + shutil.move(str(src), str(dest)) + log.info("Moved %s to recycle bin: %s", src.name, dest.name) + return dest + except OSError as e: + log.error("Failed to recycle %s: %s", src.name, e) + return None + def _delete_single_image(self, index: int): """Internal method to delete a single image by index.""" if not self.image_files or index < 0 or index >= len(self.image_files): @@ -1829,39 +1884,23 @@ def _delete_single_image(self, index: int): jpg_path = image_file.path raw_path = image_file.raw_pair - # Create recycle bin if it doesn't exist - try: - self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) - except OSError as e: - self.update_status_message(f"Failed to create recycle bin: {e}") - log.error("Failed to create recycle bin directory: %s", e) - return - # Move files to recycle bin - deleted_files = [] - try: - if jpg_path.exists(): - dest = self.recycle_bin_dir / jpg_path.name - jpg_path.rename(dest) - deleted_files.append(jpg_path.name) - log.info("Moved %s to recycle bin", jpg_path.name) - - if raw_path and raw_path.exists(): - dest = self.recycle_bin_dir / raw_path.name - raw_path.rename(dest) - deleted_files.append(raw_path.name) - log.info("Moved %s to recycle bin", raw_path.name) - - # Add to delete history only if at least one file was moved - if deleted_files: - import time - timestamp = time.time() - self.delete_history.append((jpg_path, raw_path)) - self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) + recycled_jpg = self._move_to_recycle(jpg_path) + recycled_raw = self._move_to_recycle(raw_path) if (raw_path and raw_path.exists()) else None + + # Add to delete history if anything was moved + if recycled_jpg or recycled_raw: + import time + timestamp = time.time() + # Store tuple of (src, bin_path) for each file + # Format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) + record = ( (jpg_path, recycled_jpg), (raw_path, recycled_raw) ) - except OSError as e: - self.update_status_message(f"Delete failed: {e}") - log.exception("Failed to delete image") + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + + if not recycled_jpg and not recycled_raw: + self.update_status_message("Delete failed") return # Refresh image list and move to next image @@ -1957,20 +1996,14 @@ def delete_batch_images(self): raw_path = image_file.raw_pair try: - if jpg_path.exists(): - dest = self.recycle_bin_dir / jpg_path.name - jpg_path.rename(dest) - log.info("Moved %s to recycle bin", jpg_path.name) + recycled_jpg = self._move_to_recycle(jpg_path) + recycled_raw = self._move_to_recycle(raw_path) if (raw_path and raw_path.exists()) else None - if raw_path and raw_path.exists(): - dest = self.recycle_bin_dir / raw_path.name - raw_path.rename(dest) - log.info("Moved %s to recycle bin", raw_path.name) - - # Add to delete history - self.delete_history.append((jpg_path, raw_path)) - self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) - deleted_count += 1 + if recycled_jpg or recycled_raw: + record = ( (jpg_path, recycled_jpg), (raw_path, recycled_raw) ) + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + deleted_count += 1 except OSError as e: log.exception("Failed to delete image at index %d: %s", index, e) @@ -2015,27 +2048,35 @@ def undo_delete(self): action_type, action_data, timestamp = self.undo_history.pop() if action_type == "delete": - jpg_path, raw_path = action_data - # Also remove from delete_history - if self.delete_history and self.delete_history[-1] == (jpg_path, raw_path): + # New record format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) + (jpg_src, jpg_bin), (raw_src, raw_bin) = action_data + + # Remove from delete_history if it matches + if self.delete_history and self.delete_history[-1] == action_data: self.delete_history.pop() restored_files = [] try: + # Helper to move back safely + def restore_file(src_path: Optional[Path], bin_path: Optional[Path]): + if not src_path or not bin_path or not bin_path.exists(): + return False + if src_path.exists(): + log.warning("Cannot restore %s: User file already exists at %s", bin_path.name, src_path) + return False # Or maybe restore with new name? For now, skip to prevent overwrite + + shutil.move(str(bin_path), str(src_path)) + return True + # Restore JPG - jpg_in_bin = self.recycle_bin_dir / jpg_path.name - if jpg_in_bin.exists(): - jpg_in_bin.rename(jpg_path) - restored_files.append(jpg_path.name) - log.info("Restored %s from recycle bin", jpg_path.name) + if restore_file(jpg_src, jpg_bin): + restored_files.append(jpg_src.name) + log.info("Restored %s from recycle bin", jpg_src.name) # Restore RAW - if raw_path: - raw_in_bin = self.recycle_bin_dir / raw_path.name - if raw_in_bin.exists(): - raw_in_bin.rename(raw_path) - restored_files.append(raw_path.name) - log.info("Restored %s from recycle bin", raw_path.name) + if restore_file(raw_src, raw_bin): + restored_files.append(raw_src.name) + log.info("Restored %s from recycle bin", raw_src.name) # Update status if restored_files: @@ -2049,7 +2090,7 @@ def undo_delete(self): # Find and navigate to the restored image for i, img_file in enumerate(self.image_files): - if img_file.path == jpg_path: + if img_file.path == jpg_src: self.current_index = i break @@ -2064,8 +2105,8 @@ def undo_delete(self): self.update_status_message(f"Undo failed: {e}") log.exception("Failed to restore image") # Put it back in history if it failed - self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) - self.delete_history.append((jpg_path, raw_path)) + self.undo_history.append(("delete", action_data, timestamp)) + self.delete_history.append(action_data) elif action_type == "auto_white_balance": saved_path, backup_path = action_data diff --git a/faststack/config.py b/faststack/config.py index 7aef2eb..8c4feee 100644 --- a/faststack/config.py +++ b/faststack/config.py @@ -8,6 +8,53 @@ log = logging.getLogger(__name__) +import sys +import glob +import os +import re + +def detect_rawtherapee_path(): + """Attempts to find the RawTherapee executable on Windows.""" + if sys.platform != "win32": + return None + + # Pattern to match RawTherapee installations in Program Files (both x64 and x86) + # Finds paths like C:\Program Files\RawTherapee\5.9\rawtherapee.exe + base_patterns = [ + r"C:\Program Files\RawTherapee*\**\rawtherapee.exe", + r"C:\Program Files (x86)\RawTherapee*\**\rawtherapee.exe" + ] + + try: + matches = [] + for pattern in base_patterns: + matches.extend(glob.glob(pattern, recursive=True)) + + if not matches: + return None + + # Helper to extract version numbers for natural sorting + # e.g., "5.10" -> [5, 10] + def natural_sort_key(path): + return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', path)] + + # Sort matches to try and get the latest version (by path name) + # 5.10 > 5.9 + matches.sort(key=natural_sort_key, reverse=True) + return matches[0] + except Exception as e: + log.warning(f"Error detecting RawTherapee path: {e}") + return None + + +# Determine default RawTherapee path based on OS +if sys.platform == "win32": + DEFAULT_RT_PATH = r"C:\Program Files\RawTherapee\5.12\rawtherapee.exe" +elif sys.platform == "darwin": + DEFAULT_RT_PATH = "/Applications/RawTherapee.app/Contents/MacOS/rawtherapee" +else: + DEFAULT_RT_PATH = "/usr/bin/rawtherapee" + DEFAULT_CONFIG = { "core": { "cache_size_gb": "1.5", @@ -65,6 +112,10 @@ "rgb_lower_bound": "5", "rgb_upper_bound": "250", }, + "rawtherapee": { + "exe": DEFAULT_RT_PATH, + "args": "", + }, "raw": { "source_dir": "C:\\Users\\alanr\\pictures\\olympus.stack.input.photos", "mirror_base": "C:\\Users\\alanr\\Pictures\\Lightroom", @@ -95,6 +146,17 @@ def load(self): self.config.set(section, key, value) self.save() # Save to add any missing keys + # Validate RawTherapee path (re-detect if missing) + if sys.platform == "win32": + current_rt_path = self.get("rawtherapee", "exe") + if not os.path.exists(current_rt_path): + log.warning(f"Configured RawTherapee path not found: {current_rt_path}. Attempting re-detection...") + new_path = detect_rawtherapee_path() + if new_path and new_path != current_rt_path: + log.info(f"Found new RawTherapee path: {new_path}") + self.set("rawtherapee", "exe", new_path) + self.save() + def save(self): """Saves the current configuration to the INI file.""" diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index d05d476..dc9f181 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -285,11 +285,11 @@ def _apply_edits(self, img: Image.Image, edits: Optional[Dict[str, Any]] = None, # (This remains first as it changes the coordinate system basis) rotation = edits.get('rotation', 0) if rotation == 90: - img = img.transpose(Image.Transpose.ROTATE_270) + img = img.transpose(Image.Transpose.ROTATE_90) elif rotation == 180: img = img.transpose(Image.Transpose.ROTATE_180) elif rotation == 270: - img = img.transpose(Image.Transpose.ROTATE_90) + img = img.transpose(Image.Transpose.ROTATE_270) # --------------------------------------------------------- # CHANGE: Apply Free Rotation (Straighten) BEFORE Cropping @@ -537,6 +537,10 @@ def auto_levels(self, threshold_percent: float = 0.1) -> Tuple[float, float, flo p_low = min(p_lows) p_high = max(p_highs) + # NOTE: applying this stretch uniformly to RGB can clip individual channels + # more than luminance predicts. That's usually acceptable, but if we + # ever see weird color clipping, that might be why. + # Pin ends if pre-clipping exists (prevents making it worse) if max(clipped_high_pct) > eps_pct: p_high = 255.0 diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index c7ad671..02421c4 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -320,6 +320,11 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, return None try: + # Check for empty file to avoid mmap error + if os.path.getsize(image_file.path) == 0: + log.warning("Skipping empty image file: %s", image_file.path) + return None + # Get current color management mode and optimization setting color_mode = config.get('color', 'mode', fallback="none").lower() optimize_for = config.get('core', 'optimize_for', fallback='speed').lower() @@ -515,6 +520,17 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, if color_mode == "saturation": try: factor = float(config.get('color', 'saturation_factor', fallback="1.0")) + + # Ensure buffer is contiguous and create a 1D view for saturation compensation + # Note: buffer is already made contiguous (np.ascontiguousarray) in the decode blocks above + arr = buffer.ravel() + + # Verify shape expectations + if self.debug: + assert buffer.flags['C_CONTIGUOUS'], "Buffer must be C-contiguous for in-place modification" + assert arr.size == h * bytes_per_line, f"Buffer size mismatch: {arr.size} != {h} * {bytes_per_line}" + assert arr.dtype == np.uint8, f"Buffer dtype must be uint8, got {arr.dtype}" + apply_saturation_compensation(arr, w, h, bytes_per_line, factor) t_after_saturation = time.perf_counter() diff --git a/faststack/qml/FilterDialog.qml b/faststack/qml/FilterDialog.qml index b1633aa..a3900af 100644 --- a/faststack/qml/FilterDialog.qml +++ b/faststack/qml/FilterDialog.qml @@ -82,10 +82,12 @@ Dialog { if (controller && controller.dialog_opened) { controller.dialog_opened() } + } + onClosed: { // Notify Python that dialog is closed if (controller && controller.dialog_closed) { controller.dialog_closed() } - } } + } } diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index dc27ad1..39a53ed 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -618,6 +618,7 @@ ApplicationWindow { Shortcut { sequence: "E" context: Qt.ApplicationShortcut + enabled: uiState ? !uiState.isDialogOpen : true onActivated: { if (!uiState) return diff --git a/faststack/qml/SettingsDialog.qml b/faststack/qml/SettingsDialog.qml index 9c52ace..b3ad33b 100644 --- a/faststack/qml/SettingsDialog.qml +++ b/faststack/qml/SettingsDialog.qml @@ -29,6 +29,7 @@ Window { property int theme: 0 property string defaultDirectory: "" property string photoshopPath: "" + property string rawtherapeePath: "" property string optimizeFor: "speed" property string awbMode: "lab" @@ -64,6 +65,7 @@ Window { if (uiState) { heliconPath = uiState.get_helicon_path() photoshopPath = uiState.get_photoshop_path() + rawtherapeePath = uiState.get_rawtherapee_path() cacheSize = uiState.get_cache_size() prefetchRadius = uiState.get_prefetch_radius() theme = uiState.theme @@ -99,6 +101,7 @@ Window { // Reset all text fields from properties if (heliconField.item) heliconField.item.text = settingsDialog.heliconPath if (photoshopField.item) photoshopField.item.text = settingsDialog.photoshopPath + if (rawtherapeeField.item) rawtherapeeField.item.text = settingsDialog.rawtherapeePath if (defaultDirField.item) defaultDirField.item.text = settingsDialog.defaultDirectory if (cacheSizeField.item) cacheSizeField.item.text = settingsDialog.cacheSize.toFixed(1) // Note: ComboBoxes and SpinBoxes update automatically via bindings/connections @@ -110,6 +113,7 @@ Window { function saveSettings() { uiState.set_helicon_path(heliconPath) uiState.set_photoshop_path(photoshopPath) + uiState.set_rawtherapee_path(rawtherapeePath) uiState.set_cache_size(cacheSize) uiState.set_prefetch_radius(prefetchRadius) uiState.set_theme(theme) @@ -461,6 +465,40 @@ Window { } } + // RawTherapee Path + Label { text: "RawTherapee Path"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } + RowLayout { + Layout.fillWidth: true + Loader { + id: rawtherapeeField + sourceComponent: styledTextField + Layout.fillWidth: true + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.rawtherapeePath + item.textEdited.connect(function() { settingsDialog.rawtherapeePath = item.text }) + } + } + Button { + text: "Browse" + flat: true + onClicked: { + var path = uiState.open_file_dialog() + if (path) { + settingsDialog.rawtherapeePath = path + if (rawtherapeeField.item) rawtherapeeField.item.text = path + } + } + background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + } + Label { + text: "✔" + color: "#4ade80" + visible: uiState && uiState.check_path_exists(settingsDialog.rawtherapeePath) + } + } + // Default Directory Label { text: "Default Image Directory"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } RowLayout { @@ -650,9 +688,10 @@ Window { } ToolTip.visible: clipThresholdHover.containsMouse - ToolTip.text: "Percentage of pixels to clip at the dark and light ends of the histogram when auto-levels is applied. Higher values (e.g., 5%) increase contrast but risk hard clipping. Lower values (e.g., 0.1%) preserve more dynamic range. Default: 0.1%" + ToolTip.text: "Percentage of pixels to clip at the dark and light ends of the histogram when auto-levels is applied. Higher values (e.g., 5%) increase contrast but risk making highlights appear clipped. Lower values (e.g., 0.1%) preserve more dynamic range. Default: 0.1%" } Loader { + id: clipThresholdLoader sourceComponent: styledTextField Layout.preferredWidth: 80 onLoaded: { @@ -664,10 +703,10 @@ Window { }) } Binding { - target: parent.item + target: clipThresholdLoader.item property: "text" value: settingsDialog.autoLevelClippingThreshold.toFixed(4) - when: parent.item && !parent.item.activeFocus + when: clipThresholdLoader.item && !clipThresholdLoader.item.activeFocus } } @@ -687,6 +726,7 @@ Window { RowLayout { Layout.fillWidth: true Loader { + id: autoLevelStrengthLoader sourceComponent: styledSlider Layout.fillWidth: true onLoaded: { @@ -697,10 +737,10 @@ Window { item.opacity = Qt.binding(function() { return (!autoLvlAuto.checked) ? 1.0 : 0.5 }) } Binding { - target: parent.item + target: autoLevelStrengthLoader.item property: "value" value: settingsDialog.autoLevelStrength - when: parent.item && !parent.item.pressed + when: autoLevelStrengthLoader.item && !autoLevelStrengthLoader.item.pressed } } CheckBox { @@ -787,10 +827,10 @@ Window { item.valueChanged.connect(function() { settingsDialog.awbStrength = item.value }) } Binding { - target: parent.item + target: awbStrSlider.item property: "value" value: settingsDialog.awbStrength - when: parent.item && !parent.item.pressed + when: awbStrSlider.item && !awbStrSlider.item.pressed } } @@ -809,6 +849,7 @@ Window { ToolTip.text: "Shifts the white balance warmer (yellow, positive values) or cooler (blue, negative values) after auto correction. Useful to compensate for systematic color casts. Range: -50 to +50. Default: +6" } Loader { + id: awbWarmBiasLoader sourceComponent: styledSpinBox onLoaded: { item.from = -50; item.to = 50 @@ -816,14 +857,10 @@ Window { item.valueChanged.connect(function() { settingsDialog.awbWarmBias = item.value }) } Binding { - target: parent.item + target: awbWarmBiasLoader.item property: "value" value: settingsDialog.awbWarmBias - when: parent.item && !parent.item.down // SpinBox uses down, not pressed? Or implicit pressed? SpinBox interaction is complex. - // Actually SpinBox 'value' property should be bound. SpinBox breaks binding on user input. - // 'down' property exists for internal buttons but maybe not the whole control. - // Let's assume standard Binding restoration behavior works or checking activeFocus might correspond to editing. - // Standard QtQuick Controls 2 SpinBox has 'down'. + when: awbWarmBiasLoader.item && !awbWarmBiasLoader.item.activeFocus } } @@ -842,6 +879,7 @@ Window { ToolTip.text: "Shifts the color tint toward magenta (positive values) or green (negative values) after auto correction. Compensates for tint issues in the white balance. Range: -50 to +50. Default: 0" } Loader { + id: awbTintBiasLoader sourceComponent: styledSpinBox onLoaded: { item.from = -50; item.to = 50 @@ -849,10 +887,10 @@ Window { item.valueChanged.connect(function() { settingsDialog.awbTintBias = item.value }) } Binding { - target: parent.item + target: awbTintBiasLoader.item property: "value" value: settingsDialog.awbTintBias - when: parent.item + when: awbTintBiasLoader.item } } } @@ -884,13 +922,14 @@ Window { ToolTip.text: "Minimum luminance (brightness) threshold for pixels to be included in AWB gray-point calculation. Pixels darker than this are excluded. Range: 0-255. Default: 30. Increase to ignore very dark areas." } Loader { + id: awbLumaLowerLoader sourceComponent: styledSpinBox onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaLowerBound=item.value})} Binding { - target: parent.item + target: awbLumaLowerLoader.item property: "value" value: settingsDialog.awbLumaLowerBound - when: parent.item + when: awbLumaLowerLoader.item } } @@ -908,13 +947,14 @@ Window { ToolTip.text: "Maximum luminance (brightness) threshold for pixels to be included in AWB gray-point calculation. Pixels brighter than this are excluded. Range: 0-255. Default: 220. Decrease to ignore very bright areas." } Loader { + id: awbLumaUpperLoader sourceComponent: styledSpinBox onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaUpperBound=item.value})} Binding { - target: parent.item + target: awbLumaUpperLoader.item property: "value" value: settingsDialog.awbLumaUpperBound - when: parent.item + when: awbLumaUpperLoader.item } } @@ -932,13 +972,14 @@ Window { ToolTip.text: "Minimum RGB channel value for pixels to be included in AWB calculation. Pixels with any channel below this are excluded. Range: 0-255. Default: 5. Increase to ignore very saturated colors." } Loader { + id: awbRgbLowerLoader sourceComponent: styledSpinBox onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbLowerBound=item.value})} Binding { - target: parent.item + target: awbRgbLowerLoader.item property: "value" value: settingsDialog.awbRgbLowerBound - when: parent.item + when: awbRgbLowerLoader.item } } @@ -956,13 +997,14 @@ Window { ToolTip.text: "Maximum RGB channel value for pixels to be included in AWB calculation. Pixels with any channel above this are excluded. Range: 0-255. Default: 250. Decrease to ignore near-white areas." } Loader { + id: awbRgbUpperLoader sourceComponent: styledSpinBox onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbUpperBound=item.value})} Binding { - target: parent.item + target: awbRgbUpperLoader.item property: "value" value: settingsDialog.awbRgbUpperBound - when: parent.item + when: awbRgbUpperLoader.item } } } diff --git a/faststack/tests/test_editor_rotation.py b/faststack/tests/test_editor_rotation.py index 2d8a4a6..3ab26f7 100644 --- a/faststack/tests/test_editor_rotation.py +++ b/faststack/tests/test_editor_rotation.py @@ -186,3 +186,113 @@ def test_integration_straighten_modes(): # Verify both are Green (center pixel) assert res_a.getpixel((res_a.width//2, res_a.height//2)) == (0, 255, 0) + +# ------------------------------------------------------------------------- +# Regression Tests for Rotation Direction (CW/CCW) +# ------------------------------------------------------------------------- + +def create_quadrant_image(w=100, h=100): + """ + Creates an image with 4 distinct colored quadrants. + TL: Red (255, 0, 0) + TR: Green (0, 255, 0) + BL: Blue (0, 0, 255) + BR: White (255, 255, 255) + """ + img = Image.new("RGB", (w, h)) + pixels = img.load() + + cx, cy = w // 2, h // 2 + + for y in range(h): + for x in range(w): + if x < cx and y < cy: + pixels[x, y] = (255, 0, 0) # TL Red + elif x >= cx and y < cy: + pixels[x, y] = (0, 255, 0) # TR Green + elif x < cx and y >= cy: + pixels[x, y] = (0, 0, 255) # BL Blue + else: + pixels[x, y] = (255, 255, 255) # BR White + return img + +def test_rotate_cw(): + """Test that rotate_cw rotates 90 degrees Clockwise.""" + editor = ImageEditor() + editor.original_image = create_quadrant_image(100, 100) + editor.current_filepath = "dummy.jpg" + + # Initial state: 0 rotation + assert editor.current_edits['rotation'] == 0 + + # Rotate CW (Logic in app.py subtracts 90, so local state becomes 270) + # editor.rotate_image_cw() implementation: (current - 90) % 360 + editor.rotate_image_cw() + + assert editor.current_edits['rotation'] == 270 + + # Apply edits + # PIL Transpose constants: + # ROTATE_90: 90 CCW (Left) + # ROTATE_270: 270 CCW (Right/CW) + # Expected for CW: ROTATE_270 (which maps to 270 degrees CCW) + + res = editor._apply_edits(editor.original_image.copy()) + + # Check pixels + # Original TL (Red) -> New TR + # Original TR (Green) -> New BR + # Original BL (Blue) -> New TL + # Original BR (White) -> New BL + + w, h = res.size + + # Sample center of quadrants + q_w, q_h = w // 4, h // 4 + + # New TL (Should be Blue) + assert res.getpixel((q_w, q_h)) == (0, 0, 255), "TL should be Blue (was Red)" + + # New TR (Should be Red) + assert res.getpixel((w - q_w, q_h)) == (255, 0, 0), "TR should be Red" + + # New BL (Should be White) + assert res.getpixel((q_w, h - q_h)) == (255, 255, 255), "BL should be White" + + # New BR (Should be Green) + assert res.getpixel((w - q_w, h - q_h)) == (0, 255, 0), "BR should be Green" + +def test_rotate_ccw(): + """Test that rotate_ccw rotates 90 degrees Counter-Clockwise.""" + editor = ImageEditor() + editor.original_image = create_quadrant_image(100, 100) + editor.current_filepath = "dummy.jpg" + + # Rotate CCW (Logic: current + 90) -> 90 + editor.rotate_image_ccw() + + assert editor.current_edits['rotation'] == 90 + + res = editor._apply_edits(editor.original_image.copy()) + + w, h = res.size + q_w, q_h = w // 4, h // 4 + + # CCW Rotation: + # TL (Red) -> BL + # TR (Green) -> TL + # BL (Blue) -> BR + # BR (White) -> TR + + # New TL (Should be Green) + assert res.getpixel((q_w, q_h)) == (0, 255, 0), "TL should be Green" + + # New TR (Should be White) + assert res.getpixel((w - q_w, q_h)) == (255, 255, 255), "TR should be White" + + # New BL (Should be Red) + assert res.getpixel((q_w, h - q_h)) == (255, 0, 0), "BL should be Red" + + # New BR (Should be Blue) + assert res.getpixel((w - q_w, h - q_h)) == (0, 0, 255), "BR should be Blue" + diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index f6d65ee..3791115 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -159,6 +159,7 @@ class UIState(QObject): cacheStatsChanged = Signal(str) isDecodingChanged = Signal(bool) debugModeChanged = Signal(bool) # General debug mode signal + isDialogOpenChanged = Signal(bool) # New signal for dialog state def __init__(self, app_controller): super().__init__() @@ -206,6 +207,13 @@ def __init__(self, app_controller): self._debug_cache = False self._cache_stats = "" self._is_decoding = False + self._is_dialog_open = False + + # Connect to controller's dialog state signal + self.app_controller.dialogStateChanged.connect(self._on_dialog_state_changed) + + def _on_dialog_state_changed(self, is_open: bool): + self.isDialogOpen = is_open # ---- THEME PROPERTY ---- @Property(int, notify=themeChanged) @@ -489,6 +497,14 @@ def get_photoshop_path(self): def set_photoshop_path(self, path): self.app_controller.set_photoshop_path(path) + @Slot(result=str) + def get_rawtherapee_path(self): + return self.app_controller.get_rawtherapee_path() + + @Slot(str) + def set_rawtherapee_path(self, path): + self.app_controller.set_rawtherapee_path(path) + @Slot(result=str) def open_file_dialog(self): return self.app_controller.open_file_dialog() @@ -613,6 +629,16 @@ def isEditorOpen(self, new_value: bool): self._is_editor_open = new_value self.is_editor_open_changed.emit(new_value) + @Property(bool, notify=isDialogOpenChanged) + def isDialogOpen(self) -> bool: + return self._is_dialog_open + + @isDialogOpen.setter + def isDialogOpen(self, new_value: bool): + if self._is_dialog_open != new_value: + self._is_dialog_open = new_value + self.isDialogOpenChanged.emit(new_value) + @Property(bool, notify=anySliderPressedChanged) def anySliderPressed(self): return self._any_slider_pressed diff --git a/reproduce_issue.py b/reproduce_issue.py new file mode 100644 index 0000000..ab8314c --- /dev/null +++ b/reproduce_issue.py @@ -0,0 +1,53 @@ +import os +import pathlib +import sys + +def reproduction_step(): + base_dir = pathlib.Path("test_deletion_repro") + base_dir.mkdir(exist_ok=True) + + recycle_bin = base_dir / "recycle_bin" + recycle_bin.mkdir(exist_ok=True) + + file_name = "test_image.jpg" + source_file = base_dir / file_name + dest_file = recycle_bin / file_name + + # Clean up previous run + if source_file.exists(): source_file.unlink() + if dest_file.exists(): dest_file.unlink() + + # 1. Simulate state: File exists in BOTH source and recycle bin + source_file.touch() + dest_file.touch() + + print(f"Created {source_file} and {dest_file}") + + # 2. Try rename (Current Code) + try: + print("Attempting rename (should fail on Windows)...") + source_file.rename(dest_file) + print("SUCCESS: Rename worked (unexpected on Windows if dest exists)") + except FileExistsError: + print("CAUGHT EXPECTED ERROR: FileExistsError during rename") + except OSError as e: + print(f"CAUGHT OTHER ERROR: {type(e).__name__}: {e}") + + # Reset for fix test + if not source_file.exists(): source_file.touch() + if not dest_file.exists(): dest_file.touch() + + # 3. Try replace (Proposed Fix) + try: + print("Attempting replace (should succeed)...") + source_file.replace(dest_file) + print("SUCCESS: Replace worked") + if not source_file.exists() and dest_file.exists(): + print("Verified: Source is gone, dest exists.") + else: + print("Validation FAILED: File states not correct.") + except Exception as e: + print(f"FAILED: Replace raised {type(e).__name__}: {e}") + +if __name__ == "__main__": + reproduction_step() diff --git a/reproduce_mmap_error.py b/reproduce_mmap_error.py new file mode 100644 index 0000000..67e5b49 --- /dev/null +++ b/reproduce_mmap_error.py @@ -0,0 +1,32 @@ + +import mmap +import os +import tempfile + +def reproduce(): + with tempfile.NamedTemporaryFile(delete=False) as f: + f.close() + path = f.name + + print(f"Created empty file: {path}") + try: + with open(path, "rb") as f: + # excessive logic to match the app code pattern + # "with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped:" + try: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + print("Mapped successfully (unexpected for empty file)") + except ValueError as e: + print(f"Caught expected error: {e}") + if "cannot mmap an empty file" in str(e): + print("VERIFIED: Reproduction successful.") + else: + print("VERIFIED: Reproduction successful (different message).") + + except Exception as e: + print(f"Caught unexpected top level error: {e}") + finally: + os.unlink(path) + +if __name__ == "__main__": + reproduce() diff --git a/test_deletion_repro/recycle_bin/test_image.jpg b/test_deletion_repro/recycle_bin/test_image.jpg new file mode 100644 index 0000000..e69de29 diff --git a/verify_fix.py b/verify_fix.py new file mode 100644 index 0000000..24c7f63 --- /dev/null +++ b/verify_fix.py @@ -0,0 +1,71 @@ + +import os +import sys +import logging +from pathlib import Path +import tempfile + +# Add project root to path +sys.path.insert(0, os.getcwd()) + +# Mock Qt if needed, but prefetch.py handles it. +# However, faststack.models might import Qt? +# Let's check imports if it fails. + +try: + from faststack.models import ImageFile + from faststack.imaging.prefetch import Prefetcher +except ImportError as e: + print(f"ImportError: {e}") + # Maybe need dependencies installed? + # Assuming environment is set up. + sys.exit(1) + +# Verify the fix +def verify(): + # Setup + with tempfile.NamedTemporaryFile(delete=False) as f: + f.close() + path = f.name + + print(f"Created empty file: {path}") + + try: + # Create dummy ImageFile + img_file = ImageFile(path=Path(path), name="empty.jpg", size=0, modified=0) + + def mock_cache_put(key, val): + pass + + def mock_get_info(): + return 100, 100, 1 + + # Instantiate Prefetcher + # It creates a thread pool, so we should shut it down. + prefetcher = Prefetcher([], mock_cache_put, 1, mock_get_info, debug=True) + + try: + # Call _decode_and_cache + # It checks self.generation (initially 0) against passed generation + print("Calling _decode_and_cache...") + result = prefetcher._decode_and_cache(img_file, 0, 0, 100, 100, 1) + + if result is None: + print("SUCCESS: Returned None for empty file (graceful failure).") + else: + print(f"FAILURE: Returned {result}") + finally: + prefetcher.shutdown() + + except Exception as e: + print(f"FAILED with exception: {e}") + import traceback + traceback.print_exc() + finally: + if os.path.exists(path): + os.unlink(path) + +if __name__ == "__main__": + # Configure logging to see the warning + logging.basicConfig(level=logging.INFO) + verify() diff --git a/verify_fix_simple.py b/verify_fix_simple.py new file mode 100644 index 0000000..a62f183 --- /dev/null +++ b/verify_fix_simple.py @@ -0,0 +1,37 @@ + +import mmap +import os +import tempfile + +def verify(): + # Setup + with tempfile.NamedTemporaryFile(delete=False) as f: + f.close() + path = f.name + + print(f"Created empty file: {path}") + + try: + # Verify the logic I added to prefetch.py + # Logic: + # if os.path.getsize(image_file.path) == 0: + # log.warning("Skipping empty image file: %s", image_file.path) + # return None + + if os.path.getsize(path) == 0: + print("SUCCESS: Skipped empty file due to size check.") + else: + # If we didn't skip, this would fail + with open(path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + print("Mapped successfully") + print("FAILURE: Should have skipped but didn't (or mmap worked unexpected)") + + except Exception as e: + print(f"FAILED with exception: {e}") + finally: + if os.path.exists(path): + os.unlink(path) + +if __name__ == "__main__": + verify()