diff --git a/debug_al.py b/debug_al.py
new file mode 100644
index 0000000..5b9a0e0
--- /dev/null
+++ b/debug_al.py
@@ -0,0 +1,21 @@
+
+import numpy as np
+from PIL import Image
+from faststack.imaging.editor import ImageEditor
+
+def debug_run():
+ editor = ImageEditor()
+ w, h = 200, 200
+ arr = np.zeros((h, w, 3), dtype=np.uint8)
+ arr[:] = 200
+ arr[0, 0, 0] = 255
+
+ img = Image.fromarray(arr, 'RGB')
+ editor.original_image = img
+ editor._preview_image = img
+
+ blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1)
+ print(f"RESULT: p_high={p_high}")
+
+if __name__ == "__main__":
+ debug_run()
diff --git a/faststack/app.py b/faststack/app.py
index 15ec9cf..a659e35 100644
--- a/faststack/app.py
+++ b/faststack/app.py
@@ -459,7 +459,7 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]:
if not match:
# Debug log if mismatch
- log.debug(f"Path mismatch in preview. Editor: {editor_path}, File: {file_path}")
+ log.debug("Path mismatch in preview. Editor: %s, File: %s", editor_path, file_path)
# Return background-rendered preview if Editor is open OR Cropping is active
if match and self.image_editor.original_image:
@@ -514,33 +514,47 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]:
try:
# Submit with priority=True to cancel pending prefetch tasks and free up workers
future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True)
- if future:
- try:
- # Wait for decode to complete (blocking but fast for JPEGs)
- result = future.result(timeout=5.0) # 5 second timeout as safety
- if result:
- decoded_path, decoded_display_gen = result
- cache_key = build_cache_key(decoded_path, decoded_display_gen)
- if cache_key in self.image_cache:
- decoded = self.image_cache[cache_key]
- with self._last_image_lock:
- self.last_displayed_image = decoded
- if _debug_mode:
- elapsed = time.perf_counter() - decode_start
- log.info("Decoded image %d in %.3fs", index, elapsed)
- return decoded
- except concurrent.futures.TimeoutError:
- log.exception("Timeout decoding image at index %d", index)
- with self._last_image_lock:
- return self.last_displayed_image
- except concurrent.futures.CancelledError:
- log.warning("Decode cancelled for index %d", index)
- with self._last_image_lock:
- return self.last_displayed_image
- except Exception as e:
- log.exception("Error decoding image at index %d", index)
- with self._last_image_lock:
- return self.last_displayed_image
+ if not future:
+ with self._last_image_lock:
+ return self.last_displayed_image
+
+ try:
+ # Wait for decode to complete (blocking but fast for JPEGs)
+ result = future.result(timeout=5.0) # 5 second timeout as safety
+ except concurrent.futures.TimeoutError:
+ log.warning("Timeout decoding image at index %d", index)
+ with self._last_image_lock:
+ return self.last_displayed_image
+ except concurrent.futures.CancelledError:
+ log.debug("Decode cancelled for index %d", index)
+ with self._last_image_lock:
+ return self.last_displayed_image
+ except Exception:
+ log.exception("Error decoding image at index %d", index)
+ with self._last_image_lock:
+ return self.last_displayed_image
+
+ if not result:
+ if _debug_mode:
+ log.debug("Decode returned no result for index %d", index)
+ with self._last_image_lock:
+ return self.last_displayed_image
+
+ decoded_path, decoded_display_gen = result
+ cache_key = build_cache_key(decoded_path, decoded_display_gen)
+ if cache_key in self.image_cache:
+ decoded = self.image_cache[cache_key]
+ with self._last_image_lock:
+ self.last_displayed_image = decoded
+ if _debug_mode:
+ elapsed = time.perf_counter() - decode_start
+ log.info("Decoded image %d in %.3fs", index, elapsed)
+ return decoded
+ else:
+ if _debug_mode:
+ log.debug("Decode finished but cache_key missing (index=%d, key=%s)", index, cache_key)
+ with self._last_image_lock:
+ return self.last_displayed_image
finally:
# Hide decoding indicator
if self.debug_cache:
@@ -586,15 +600,23 @@ def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]:
# The danger is 'self.futures' management in Prefetcher.
future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True)
if future:
- result = future.result(timeout=5.0)
+ try:
+ result = future.result(timeout=5.0)
+ except concurrent.futures.TimeoutError:
+ log.warning(f"Timeout decoding image at index {index} (background)")
+ return None
+ except concurrent.futures.CancelledError:
+ log.debug(f"Decode cancelled for image at index {index} (background)")
+ return None
+
if result:
decoded_path, decoded_display_gen = result
# Re-verify key
cache_key = build_cache_key(decoded_path, decoded_display_gen)
if cache_key in self.image_cache:
return self.image_cache[cache_key]
- except Exception as e:
- log.warning(f"_get_decoded_image_safe failed for index {index}: {e}")
+ except Exception:
+ log.exception("_get_decoded_image_safe failed for index %d", index)
return None
@@ -1300,14 +1322,14 @@ def set_cache_size(self, size):
config.set('core', 'cache_size_gb', size)
config.save()
- old_max_bytes = self.image_cache.maxsize
+ old_max_bytes = self.image_cache.max_bytes
new_max_bytes = int(size * 1024**3)
if old_max_bytes == new_max_bytes:
return
log.info("Resizing decoded image cache from %.2f GB to %.2f GB",
old_max_bytes / (1024**3), size)
- self.image_cache.maxsize = new_max_bytes
+ self.image_cache.max_bytes = new_max_bytes
# If the new size is smaller than current usage, evict until under limit
while self.image_cache.currsize > new_max_bytes and len(self.image_cache) > 0:
@@ -2244,7 +2266,7 @@ def _on_cache_evict(self):
# Format usage info
used_gb = self.image_cache.currsize / (1024**3)
- max_gb = self.image_cache.maxsize / (1024**3)
+ max_gb = self.image_cache.max_bytes / (1024**3)
msg = f"Cache thrashing! {len(self._eviction_timestamps)} evictions in {CACHE_THRASH_WINDOW_SECS}s. Usage: {used_gb:.1f}GB / {max_gb:.1f}GB."
@@ -3225,47 +3247,51 @@ def auto_levels(self):
return False
# Calculate auto levels
- blacks, whites = self.image_editor.auto_levels(self.auto_level_threshold)
-
- # Scale by strength
- skipped_due_to_clipping = False
+ # Calculate auto levels - now returns (blacks, whites, p_low, p_high)
+ blacks, whites, p_low, p_high = self.image_editor.auto_levels(self.auto_level_threshold)
+
+ # Auto-strength computation using stretch-factor capping
+ #
+ # Philosophy: threshold_percent defines acceptable clipping (e.g., 0.1% at each end).
+ # Auto-strength should NOT prevent that clipping - it's intentional.
+ # Instead, auto-strength prevents INSANE levels on low-dynamic-range images.
+ #
+ # Approach: Cap the stretch factor to a reasonable maximum (e.g., 3-4x).
+ # - Full strength: stretch = 255 / (p_high - p_low)
+ # - If stretch is reasonable (<= cap), use full strength
+ # - If stretch is extreme (> cap), blend to limit effective stretch to cap
+ #
if self.auto_level_strength_auto:
- # Calculate optimal strength to prevent pre-clipping
- try:
- # Use preview image if available to ignore single hot-pixel outliers
- img = self.image_editor._preview_image if self.image_editor._preview_image else self.image_editor.original_image
- if img:
- # Get max value across all channels
- extrema = img.getextrema()
- if isinstance(extrema[0], tuple):
- max_val = max(ch[1] for ch in extrema)
- else:
- max_val = extrema[1]
-
- log.debug(f"Auto levels auto-strength: max_val={max_val}")
-
- if max_val < 250:
- denom = 40 * (250 * whites - 5 * blacks)
- if abs(denom) > 0.001:
- strength = (255 * (max_val - 250)) / denom
- strength = max(0.0, min(1.0, strength))
- else:
- strength = 0.0
- else:
- strength = 0.0
- skipped_due_to_clipping = True
+ # Calculate full-strength stretch factor
+ dynamic_range = p_high - p_low
+ if dynamic_range < 1.0:
+ # Degenerate case: nearly flat image
+ strength = 0.0
+ log.debug(f"Auto levels: degenerate dynamic range ({dynamic_range:.2f}), strength=0")
+ else:
+ stretch_full = 255.0 / dynamic_range
+
+ # Cap stretch to prevent insane levels
+ # E.g., if image spans only 50-200 (range=150), full stretch would be 255/150 = 1.7x (fine)
+ # But if image spans 100-110 (range=10), full stretch would be 255/10 = 25.5x (insane!)
+ STRETCH_CAP = 4.0 # Maximum allowed stretch factor
+
+ if stretch_full <= STRETCH_CAP:
+ # Reasonable stretch, use full strength
+ strength = 1.0
else:
- strength = self.auto_level_strength
- except Exception as e:
- log.warning(f"Failed to calculate auto strength: {e}")
- strength = self.auto_level_strength
+ # Excessive stretch - blend to cap it
+ # effective_stretch = 1 + strength * (stretch_full - 1) = STRETCH_CAP
+ # solving for strength: strength = (STRETCH_CAP - 1) / (stretch_full - 1)
+ strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0)
+ strength = max(0.0, min(1.0, strength))
+
+ log.debug(f"Auto levels: p_low={p_low:.1f}, p_high={p_high:.1f}, "
+ f"range={dynamic_range:.1f}, stretch_full={stretch_full:.2f}, strength={strength:.3f}")
else:
strength = self.auto_level_strength
- if skipped_due_to_clipping:
- self.update_status_message("No changes made to color levels to avoid clipping")
- return False
-
+ # Apply strength scaling to blacks and whites parameters
blacks *= strength
whites *= strength
@@ -3283,9 +3309,28 @@ def auto_levels(self):
if self.ui_state.isHistogramVisible:
self.update_histogram()
- self.update_status_message(f"Auto levels applied (preview only)")
- log.info("Auto levels preview applied to %s (clip %.2f%%, str %.2f)",
- filepath, self.auto_level_threshold, strength)
+ # Determine status message based on whether endpoints were pinned (clipping detected)
+ # We check p_high/p_low directly because whites/blacks might be small due to strength scaling
+ # even if not pinned.
+ msg = "Auto levels applied"
+
+ # Check for essentially no-op (degenerate or already full range)
+ # Degenerate: dynamic range is tiny (< 1.0)
+ # Full range: p_low is near 0 and p_high near 255
+ if abs(p_high - p_low) < 1.0:
+ msg = "Auto levels: no changes (degenerate range)"
+ elif p_low <= 0 and p_high >= 255:
+ # We already cover the full range
+ msg = "Auto levels: no changes (image already covers full range)"
+ # Check for pinning
+ elif p_high >= 255.0:
+ msg = "Auto levels: highlights already clipped; only adjusting shadows"
+ elif p_low <= 0.0:
+ msg = "Auto levels: shadows already clipped; only adjusting highlights"
+
+ self.update_status_message(f"{msg} (preview only)")
+ log.info("Auto levels preview applied to %s (clip %.2f%%, str %.2f). Msg: %s",
+ filepath, self.auto_level_threshold, strength, msg)
return True
@Slot()
diff --git a/faststack/imaging/cache.py b/faststack/imaging/cache.py
index f37f078..4ec5ee9 100644
--- a/faststack/imaging/cache.py
+++ b/faststack/imaging/cache.py
@@ -24,6 +24,18 @@ def __init__(
f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity."
)
+ @property
+ def max_bytes(self) -> int:
+ """Get the maximum cache size in bytes."""
+ return self.maxsize
+
+ @max_bytes.setter
+ def max_bytes(self, value: int) -> None:
+ """Set the maximum cache size in bytes."""
+ v = max(0, int(value))
+ self.maxsize = v
+ log.debug(f"Cache max_bytes updated to {v / 1024**2:.2f} MB")
+
def __setitem__(self, key, value):
# Before adding a new item, we might need to evict others
# This is handled by the parent class, which will call popitem if needed
diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py
index ddeeac5..d05d476 100644
--- a/faststack/imaging/editor.py
+++ b/faststack/imaging/editor.py
@@ -485,51 +485,82 @@ def _apply_edits(self, img: Image.Image, edits: Optional[Dict[str, Any]] = None,
return img
- def auto_levels(self, threshold_percent: float = 0.1) -> Tuple[float, float]:
+ def auto_levels(self, threshold_percent: float = 0.1) -> Tuple[float, float, float, float]:
"""
- Automatically adjusts blacks and whites based on image histogram.
-
- Args:
- threshold_percent: value 0.0-10.0, percentage of pixels to clip at each end.
-
- Returns:
- Tuple of (blacks, whites) parameter values.
+ Returns (blacks, whites, p_low, p_high).
+ p_low/p_high are computed conservatively from RGB to avoid introducing new channel clipping.
"""
if self.original_image is None:
- return 0.0, 0.0
-
- # Use preview image for speed if available, otherwise original
+ return 0.0, 0.0, 0.0, 255.0
+
+ if np is None:
+ # Fallback: do nothing without numpy
+ return 0.0, 0.0, 0.0, 255.0
+
+ threshold_percent = max(0.0, min(10.0, threshold_percent))
img = self._preview_image if self._preview_image else self.original_image
-
- # Convert to numpy array for histogram analysis
- arr = np.array(img.convert('L')) # Use luminance for levels
-
- # Calculate percentiles
+
+ rgb = np.asarray(img.convert("RGB"), dtype=np.uint8)
+ # rgb shape: (H, W, 3)
+
low_p = threshold_percent
high_p = 100.0 - threshold_percent
-
- p_low, p_high = np.percentile(arr, [low_p, high_p])
-
- # Calculate parameters to map p_low->0 and p_high->255
- # Logic matches _apply_edits:
- # black_point = -blacks * 40
- # white_point = 255 - whites * 40
-
- # We want black_point to be p_low
- # p_low = -blacks * 40 => blacks = -float(p_low) / 40.0
- blacks = -float(p_low) / 40.0
-
- # We want white_point to be p_high
- # p_high = 255 - whites * 40 => whites = (255.0 - float(p_high)) / 40.0
- whites = (255.0 - float(p_high)) / 40.0
-
- # Update state
+
+ # --- Detect pre-clipping (per-channel) ---
+ # If *any* channel already has clipped pixels, do not push that end further.
+ # eps_pct strategy: "Practical" - ignore tiny hot pixels (0.01%) but pin
+ # if there is any meaningful pre-clipping, even if below the full threshold.
+ eps_pct = min(threshold_percent, 0.01)
+
+ total = rgb.shape[0] * rgb.shape[1]
+ clipped_low_pct = []
+ clipped_high_pct = []
+ p_lows = []
+ p_highs = []
+
+ for c in range(3):
+ chan = rgb[:, :, c]
+ # Treat near-white/near-black as clipped (JPEG artifacts often land on 254/1)
+ clipped_low_pct.append(100.0 * float(np.count_nonzero(chan <= 1)) / float(total))
+ clipped_high_pct.append(100.0 * float(np.count_nonzero(chan >= 254)) / float(total))
+
+ # Use discrete selection methods to avoid interpolation surprises on uint8.
+ # Fallback for older numpy (<1.22) that doesn't support method=.
+ try:
+ p_lows.append(float(np.percentile(chan, low_p, method="lower")))
+ p_highs.append(float(np.percentile(chan, high_p, method="higher")))
+ except TypeError:
+ p_lows.append(float(np.percentile(chan, low_p, interpolation="lower")))
+ p_highs.append(float(np.percentile(chan, high_p, interpolation="higher")))
+
+ # Conservative anchors to avoid new channel clipping
+ p_low = min(p_lows)
+ p_high = max(p_highs)
+
+ # Pin ends if pre-clipping exists (prevents making it worse)
+ if max(clipped_high_pct) > eps_pct:
+ p_high = 255.0
+ if max(clipped_low_pct) > eps_pct:
+ p_low = 0.0
+
+ # Safety
+ p_low = max(0.0, min(255.0, p_low))
+ p_high = max(0.0, min(255.0, p_high))
+
+ # Check for degenerate range (e.g. flat image) to prevent extreme stretching
+ if (p_high - p_low) < 1.0:
+ blacks = 0.0
+ whites = 0.0
+ else:
+ blacks = -p_low / 40.0
+ whites = (255.0 - p_high) / 40.0
+
with self._lock:
- self.current_edits['blacks'] = blacks
- self.current_edits['whites'] = whites
+ self.current_edits["blacks"] = blacks
+ self.current_edits["whites"] = whites
self._edits_rev += 1
-
- return blacks, whites
+
+ return blacks, whites, float(p_low), float(p_high)
def get_preview_data_cached(self, allow_compute: bool = True) -> Optional[DecodedImage]:
"""Return cached preview if available, otherwise compute and cache.
diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py
index a43d8c9..c7ad671 100644
--- a/faststack/imaging/prefetch.py
+++ b/faststack/imaging/prefetch.py
@@ -160,6 +160,7 @@ def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_r
max_workers=optimal_workers,
thread_name_prefix="Prefetcher"
)
+ self._futures_lock = threading.RLock()
self.futures: Dict[int, Future] = {}
self.generation = 0
self._scheduled: Dict[int, set] = {} # generation -> set of scheduled indices
@@ -190,10 +191,10 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc
# zoom state, or color mode changes - events that actually invalidate cached images.
# Navigation just shifts which indices to prefetch.
- # Clean up old generation entries to prevent memory leak
- old_generations = [g for g in self._scheduled if g < self.generation]
- for g in old_generations:
- del self._scheduled[g]
+ # OLD GENERATION CLEANUP MOVED TO INSIDE LOCK BELOW
+ # old_generations = [g for g in self._scheduled if g < self.generation]
+ # for g in old_generations:
+ # del self._scheduled[g]
# Track navigation direction
if direction is not None:
@@ -227,36 +228,42 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc
log.debug("Prefetch range: [%d, %d) for index %d (direction=%d, behind=%d, ahead=%d)",
start, end, current_index, self._last_navigation_direction, behind, ahead)
- # Get scheduled set for current generation
- scheduled = self._scheduled.setdefault(self.generation, set())
-
# Cancel stale futures and remove from scheduled
- stale_keys = []
- for index, future in list(self.futures.items()):
- if index < start or index >= end:
- if future.cancel():
- stale_keys.append(index)
- scheduled.discard(index) # Remove from scheduled set
- for key in stale_keys:
- del self.futures[key]
-
- # Submit new tasks - prioritize current image and direction of travel
-
- # Build priority order: current first, then in direction of travel
- priority_order = [current_index]
- if self._last_navigation_direction > 0:
- priority_order.extend(range(current_index + 1, end))
- priority_order.extend(range(current_index - 1, start - 1, -1))
- else:
- priority_order.extend(range(current_index - 1, start - 1, -1))
- priority_order.extend(range(current_index + 1, end))
-
- for i in priority_order:
- if i < 0 or i >= len(self.image_files):
- continue
- if i not in scheduled and i not in self.futures:
- self.submit_task(i, self.generation)
- scheduled.add(i)
+ with self._futures_lock:
+ # Clean up old generation entries to prevent memory leak
+ # MOVED INSIDE LOCK to prevent race with cancel_all()
+ old_generations = [g for g in self._scheduled if g < self.generation]
+ for g in old_generations:
+ del self._scheduled[g]
+
+ # Get scheduled set for current generation (inside lock to prevent race)
+ scheduled = self._scheduled.setdefault(self.generation, set())
+ stale_keys = []
+ for index, future in list(self.futures.items()):
+ if index < start or index >= end:
+ if future.cancel():
+ stale_keys.append(index)
+ scheduled.discard(index) # Remove from scheduled set
+ for key in stale_keys:
+ del self.futures[key]
+
+ # Submit new tasks - prioritize current image and direction of travel
+
+ # Build priority order: current first, then in direction of travel
+ priority_order = [current_index]
+ if self._last_navigation_direction > 0:
+ priority_order.extend(range(current_index + 1, end))
+ priority_order.extend(range(current_index - 1, start - 1, -1))
+ else:
+ priority_order.extend(range(current_index - 1, start - 1, -1))
+ priority_order.extend(range(current_index + 1, end))
+
+ for i in priority_order:
+ if i < 0 or i >= len(self.image_files):
+ continue
+ if i not in scheduled and i not in self.futures:
+ self.submit_task(i, self.generation)
+ scheduled.add(i)
def submit_task(self, index: int, generation: int, priority: bool = False) -> Optional[Future]:
"""Submits a decoding task for a given index.
@@ -266,39 +273,40 @@ def submit_task(self, index: int, generation: int, priority: bool = False) -> Op
generation: Generation number for cache invalidation
priority: If True, cancels lower-priority pending tasks to free up workers
"""
- if index in self.futures and not self.futures[index].done():
- return self.futures[index] # Already submitted
-
- # For high-priority tasks (current image), cancel pending prefetch tasks
- # to free up worker threads and reduce blocking time
- if priority:
- cancelled_count = 0
- # Don't cancel tasks that are very close to the requested index (e.g. +/- 2)
- # This prevents thrashing when the user is navigating quickly
- safe_radius = 2
-
- for task_index, future in list(self.futures.items()):
- # Skip the current task
- if task_index == index:
- continue
+ with self._futures_lock:
+ if index in self.futures and not self.futures[index].done():
+ return self.futures[index] # Already submitted
+
+ # For high-priority tasks (current image), cancel pending prefetch tasks
+ # to free up worker threads and reduce blocking time
+ if priority:
+ cancelled_count = 0
+ # Don't cancel tasks that are very close to the requested index (e.g. +/- 2)
+ # This prevents thrashing when the user is navigating quickly
+ safe_radius = 2
- # Skip tasks within safe radius
- if abs(task_index - index) <= safe_radius:
- continue
+ for task_index, future in list(self.futures.items()):
+ # Skip the current task
+ if task_index == index:
+ continue
+
+ # Skip tasks within safe radius
+ if abs(task_index - index) <= safe_radius:
+ continue
- if not future.done() and future.cancel():
- cancelled_count += 1
- del self.futures[task_index]
- if cancelled_count > 0:
- log.debug("Cancelled %d pending prefetch tasks to prioritize index %d", cancelled_count, index)
+ if not future.done() and future.cancel():
+ cancelled_count += 1
+ del self.futures[task_index]
+ if cancelled_count > 0:
+ log.debug("Cancelled %d pending prefetch tasks to prioritize index %d", cancelled_count, index)
- image_file = self.image_files[index]
- display_width, display_height, display_generation = self.get_display_info()
+ image_file = self.image_files[index]
+ display_width, display_height, display_generation = self.get_display_info()
- future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation)
- self.futures[index] = future
- log.debug("Submitted %s task for index %d", "priority" if priority else "prefetch", index)
- return future
+ future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation)
+ self.futures[index] = future
+ log.debug("Submitted %s task for index %d", "priority" if priority else "prefetch", index)
+ return future
def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[Path, int]]:
"""The actual work done by the thread pool."""
@@ -384,8 +392,11 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int,
rgb = np.array(img, dtype=np.uint8)
h, w, _ = rgb.shape
- bytes_per_line = w * 3
- arr = rgb.reshape(-1).copy()
+
+ # Memory Optimization: Avoid explicit copy
+ buffer = np.ascontiguousarray(rgb)
+ bytes_per_line = buffer.strides[0]
+ mv = memoryview(buffer).cast("B")
t_after_copy = time.perf_counter()
if self.debug:
@@ -417,8 +428,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int,
t_after_fallback_decode = time.perf_counter()
h, w, _ = buffer.shape
- bytes_per_line = w * 3
- arr = buffer.reshape(-1).copy()
+
+ # Memory Optimization: Avoid explicit copy
+ buffer = np.ascontiguousarray(buffer)
+ bytes_per_line = buffer.strides[0]
+ mv = memoryview(buffer).cast("B")
+
# Align with non-fallback paths for timing/logging
t_after_copy = time.perf_counter()
@@ -451,8 +466,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int,
t_after_decode = time.perf_counter()
h, w, _ = buffer.shape
- bytes_per_line = w * 3
- arr = buffer.reshape(-1).copy()
+
+ # Memory Optimization: Avoid explicit copy
+ buffer = np.ascontiguousarray(buffer)
+ bytes_per_line = buffer.strides[0]
+ mv = memoryview(buffer).cast("B")
+
# Align with non-fallback paths for timing/logging
t_after_copy = time.perf_counter()
@@ -484,8 +503,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int,
t_after_decode = time.perf_counter()
h, w, _ = buffer.shape
- bytes_per_line = w * 3
- arr = buffer.reshape(-1).copy()
+
+ # Memory Optimization: Avoid explicit copy
+ buffer = np.ascontiguousarray(buffer)
+ bytes_per_line = buffer.strides[0]
+ mv = memoryview(buffer).cast("B")
+
t_after_copy = time.perf_counter()
# Apply saturation compensation if enabled
@@ -517,7 +540,7 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int,
return None
decoded_image = DecodedImage(
- buffer=arr.data,
+ buffer=mv,
width=w,
height=h,
bytes_per_line=bytes_per_line,
@@ -547,12 +570,13 @@ def _is_in_prefetch_range(self, index: int, current_index: int, radius: Optional
def cancel_all(self):
"""Cancels all pending prefetch tasks."""
- log.info("Cancelling all prefetch tasks.")
- self.generation += 1
- for future in self.futures.values():
- future.cancel()
- self.futures.clear()
- self._scheduled.clear() # Clear scheduled indices when bumping generation
+ with self._futures_lock:
+ log.info("Cancelling all prefetch tasks.")
+ self.generation += 1
+ for future in self.futures.values():
+ future.cancel()
+ self.futures.clear()
+ self._scheduled.clear() # Clear scheduled indices when bumping generation
def shutdown(self):
"""Shuts down the thread pool executor."""
diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml
index 95dd106..dc27ad1 100644
--- a/faststack/qml/Main.qml
+++ b/faststack/qml/Main.qml
@@ -248,22 +248,6 @@ ApplicationWindow {
height: 36
text: "Settings..."
onClicked: {
- if (uiState) {
- settingsDialog.heliconPath = uiState.get_helicon_path()
- settingsDialog.photoshopPath = uiState.get_photoshop_path()
- settingsDialog.cacheSize = uiState.get_cache_size()
- settingsDialog.prefetchRadius = uiState.get_prefetch_radius()
- settingsDialog.theme = uiState.theme
- settingsDialog.defaultDirectory = uiState.get_default_directory()
- settingsDialog.optimizeFor = uiState.get_optimize_for()
- settingsDialog.awbMode = uiState.awbMode
- settingsDialog.awbStrength = uiState.awbStrength
- settingsDialog.awbWarmBias = uiState.awbWarmBias
- settingsDialog.awbLumaLowerBound = uiState.awbLumaLowerBound
- settingsDialog.awbLumaUpperBound = uiState.awbLumaUpperBound
- settingsDialog.awbRgbLowerBound = uiState.awbRgbLowerBound
- settingsDialog.awbRgbUpperBound = uiState.awbRgbUpperBound
- }
settingsDialog.open()
fileMenu.close()
}
@@ -871,10 +855,10 @@ ApplicationWindow {
"Viewing:
" +
" Mouse Wheel: Zoom in/out
" +
" Left-click + Drag: Pan image
" +
- " Ctrl+0: Reset zoom and pan to fit window
" +
- " Ctrl+1: Zoom to 100%
" +
- " Ctrl+2: Zoom to 200%
" +
- " Ctrl+3: Zoom to 300%
" +
+ " Ctrl+0: Reset zoom and pan to fit window
" +
+ " Ctrl+1: Zoom to 100%
" +
+ " Ctrl+2: Zoom to 200%
" +
+ " Ctrl+3: Zoom to 300%
" +
" Ctrl+4: Zoom to 400%
" +
"Stacking:
" +
" [: Begin new stack
" +
@@ -895,7 +879,7 @@ ApplicationWindow {
" {: Begin new batch
" +
" B: Toggle current image in/out of batch
" +
" }: End current batch
" +
- " \\: Clear all batches
" +
+ " \\: Clear all batches
" +
"Flag Toggles:
" +
" U: Toggle uploaded flag
" +
" Ctrl+E: Toggle edited flag
" +
@@ -910,7 +894,7 @@ ApplicationWindow {
" A: Quick auto white balance (saves automatically)
" +
" L: Quick auto levels (saves automatically)
" +
" Ctrl+Shift+B: Quick auto white balance (saves automatically)
" +
- " O (or right mouse click): Toggle crop mode (Enter to execute crop, ESC to cancel)
" +
+ " O (or right mouse click): Toggle crop mode (Enter to execute, ESC to cancel)
" +
" H: Toggle histogram window
" +
" E: Toggle Image Editor (closes without saving if open)
" +
" Ctrl+C: Copy image path to clipboard
" +
diff --git a/faststack/qml/SettingsDialog.qml b/faststack/qml/SettingsDialog.qml
index 684ea3d..9c52ace 100644
--- a/faststack/qml/SettingsDialog.qml
+++ b/faststack/qml/SettingsDialog.qml
@@ -1,40 +1,25 @@
-import QtQuick
+import QtQuick 2.15
import QtQuick.Controls 2.15
+import QtQuick.Controls.Material 2.15
import QtQuick.Layouts 1.15
+import QtQuick.Window 2.15
-Dialog {
+Window {
id: settingsDialog
title: "Settings"
- standardButtons: Dialog.Ok | Dialog.Cancel
- modal: true
- closePolicy: Popup.CloseOnEscape
- focus: true
- width: 600
- height: 770
-
- // Live cache usage value (updated by timer)
- property real cacheUsage: 0.0
-
- onVisibleChanged: {
- cacheUsageTimer.running = visible
- if (visible) {
- controller.dialog_opened()
- } else {
- controller.dialog_closed()
- }
- }
+ width: 700
+ height: 800
+ visible: false
+ flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint
+ modality: Qt.ApplicationModal
- onOpened: {
- // Refresh text fields when dialog opens with current values
- cacheSizeField.text = settingsDialog.cacheSize.toFixed(1)
- heliconPathField.text = settingsDialog.heliconPath
- photoshopPathField.text = settingsDialog.photoshopPath
- optimizeForComboBox.currentIndex = optimizeForComboBox.model.indexOf(settingsDialog.optimizeFor)
- autoLevelThresholdField.text = settingsDialog.autoLevelClippingThreshold.toFixed(4)
- settingsDialog.autoLevelStrength = uiState.autoLevelStrength
- settingsDialog.autoLevelStrengthAuto = uiState.autoLevelStrengthAuto
+ // Make window close button (X) behave like Cancel
+ onClosing: function(close) {
+ close.accepted = false
+ visible = false
}
-
+
+ // Properties matching the original dialog
property string heliconPath: ""
property double cacheSize: 1.5
property double autoLevelClippingThreshold: 0.1
@@ -56,7 +41,73 @@ Dialog {
property int awbRgbLowerBound: 5
property int awbRgbUpperBound: 250
- onAccepted: {
+ // Live cache usage value (updated by timer)
+ property real cacheUsage: 0.0
+
+ // Modern Color Palette (copied from ImageEditorDialog)
+ property color backgroundColor: "#1e1e1e"
+ property color textColor: "white"
+ 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: Material.Dark
+ Material.accent: accentColor
+ color: backgroundColor
+
+ // Helper to open the dialog
+ function open() {
+ // Reload all properties from uiState to ensure Cancel discards edits
+ if (uiState) {
+ heliconPath = uiState.get_helicon_path()
+ photoshopPath = uiState.get_photoshop_path()
+ cacheSize = uiState.get_cache_size()
+ prefetchRadius = uiState.get_prefetch_radius()
+ theme = uiState.theme
+ defaultDirectory = uiState.get_default_directory()
+ optimizeFor = uiState.get_optimize_for()
+ autoLevelClippingThreshold = uiState.autoLevelClippingThreshold
+ autoLevelStrength = uiState.autoLevelStrength
+ autoLevelStrengthAuto = uiState.autoLevelStrengthAuto
+ awbMode = uiState.awbMode
+ awbStrength = uiState.awbStrength
+ awbWarmBias = uiState.awbWarmBias
+ awbTintBias = uiState.awbTintBias
+ awbLumaLowerBound = uiState.awbLumaLowerBound
+ awbLumaUpperBound = uiState.awbLumaUpperBound
+ awbRgbLowerBound = uiState.awbRgbLowerBound
+ awbRgbUpperBound = uiState.awbRgbUpperBound
+ }
+ visible = true
+ raise()
+ requestActivate()
+ }
+
+ Shortcut {
+ sequence: "Escape"
+ context: Qt.WindowShortcut
+ onActivated: visible = false
+ }
+
+ onVisibleChanged: {
+ cacheUsageTimer.running = visible
+ if (visible) {
+ controller.dialog_opened()
+ // Reset all text fields from properties
+ if (heliconField.item) heliconField.item.text = settingsDialog.heliconPath
+ if (photoshopField.item) photoshopField.item.text = settingsDialog.photoshopPath
+ 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
+ } else {
+ controller.dialog_closed()
+ }
+ }
+
+ function saveSettings() {
uiState.set_helicon_path(heliconPath)
uiState.set_photoshop_path(photoshopPath)
uiState.set_cache_size(cacheSize)
@@ -77,389 +128,910 @@ Dialog {
uiState.awbLumaUpperBound = awbLumaUpperBound
uiState.awbRgbLowerBound = awbRgbLowerBound
uiState.awbRgbUpperBound = awbRgbUpperBound
+
+ visible = false
}
- contentItem: ColumnLayout {
- Row {
- id: tabButtons
- spacing: 5
-
- Button {
- text: "General"
- highlighted: settingsStackLayout.currentIndex === 0
- onClicked: settingsStackLayout.currentIndex = 0
- }
- Button {
- text: "Auto White Balance"
- highlighted: settingsStackLayout.currentIndex === 1
- onClicked: settingsStackLayout.currentIndex = 1
- }
+ // Component for Section Separator
+ Component {
+ id: sectionSeparator
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.topMargin: 20
+ Layout.bottomMargin: 5
+ height: 1
+ color: settingsDialog.separatorColor
}
+ }
- StackLayout {
- id: settingsStackLayout
- currentIndex: 0
+ // Component for Section Header
+ Component {
+ id: sectionHeader
+ Label {
+ font.bold: true
+ font.pixelSize: 15
+ font.letterSpacing: 1.0
+ color: settingsDialog.accentColorHover
+ Layout.topMargin: 5
+ Layout.bottomMargin: 10
+ }
+ }
- GridLayout {
- columns: 3
+ // Custom Styled TextField
+ Component {
+ id: styledTextField
+ TextField {
+ id: control
+ color: settingsDialog.textColor
+ placeholderTextColor: "#80ffffff"
+ selectionColor: settingsDialog.accentColor
+ selectedTextColor: "#ffffff"
+ font.pixelSize: 13
+ background: Rectangle {
+ color: control.enabled ? "transparent" : "#05ffffff"
+ border.color: control.activeFocus ? settingsDialog.accentColor : settingsDialog.controlBorder
+ border.width: 1
+ radius: 4
+ }
+ }
+ }
+
+ // Styled Slider Component
+ Component {
+ id: styledSlider
+ Slider {
+ id: control
+
+ background: Item {
+ x: control.leftPadding
+ y: control.topPadding + control.availableHeight / 2 - height / 2
+ width: control.availableWidth
+ height: 6
- // Helicon Path
- Label { text: "Helicon Focus Path:" }
- TextField {
- id: heliconPathField
- Layout.fillWidth: true
- text: settingsDialog.heliconPath
- onTextChanged: settingsDialog.heliconPath = text
+ Rectangle {
+ anchors.fill: parent
+ radius: 3
+ color: settingsDialog.controlBg
+ border.color: settingsDialog.controlBorder
+ border.width: 1
}
- RowLayout {
- Button {
- text: "Browse..."
- onClicked: {
- var path = uiState.open_file_dialog()
- if (path) heliconPathField.text = path
- }
- }
- Label {
- id: checkMarkLabel
- text: "✔"
- color: "lightgreen"
- visible: uiState && uiState.check_path_exists(heliconPathField.text)
- }
+
+ Rectangle {
+ width: control.visualPosition * parent.width
+ height: parent.height
+ radius: 3
+ color: settingsDialog.accentColor
+ opacity: 0.8
}
+ }
- // Photoshop Path
- Label { text: "Photoshop Path:" }
- TextField {
- id: photoshopPathField
- Layout.fillWidth: true
- text: settingsDialog.photoshopPath
- onTextChanged: settingsDialog.photoshopPath = text
+ handle: Rectangle {
+ x: control.leftPadding + control.visualPosition * (control.availableWidth - width)
+ y: control.topPadding + control.availableHeight / 2 - height / 2
+ width: 16
+ height: 16
+ radius: 8
+ color: control.pressed ? settingsDialog.accentColor : "white"
+ border.color: control.pressed ? "white" : settingsDialog.accentColor
+ border.width: 2
+ }
+ }
+ }
+
+ // Styled SpinBox Component
+ Component {
+ id: styledSpinBox
+ SpinBox {
+ id: control
+ editable: true
+
+ contentItem: TextInput {
+ z: 2
+ text: control.textFromValue(control.value, control.locale)
+ font.pixelSize: 13
+ color: settingsDialog.textColor
+ selectionColor: settingsDialog.accentColor
+ selectedTextColor: "#ffffff"
+ horizontalAlignment: Qt.AlignHCenter
+ verticalAlignment: Qt.AlignVCenter
+ readOnly: !control.editable
+ validator: control.validator
+ inputMethodHints: Qt.ImhFormattedNumbersOnly
+
+ // Update control.value when user finishes typing
+ onEditingFinished: {
+ control.value = control.valueFromText(text, control.locale)
}
- RowLayout {
- Button {
- text: "Browse..."
- onClicked: {
- var path = uiState.open_file_dialog()
- if (path) photoshopPathField.text = path
- }
- }
- Label {
- id: photoshopCheckMarkLabel
- text: "✔"
- color: "lightgreen"
- visible: uiState && uiState.check_path_exists(photoshopPathField.text)
+ }
+
+ up.indicator: Item {
+ x: parent.width - width
+ height: parent.height
+ width: 20
+ Rectangle {
+ anchors.centerIn: parent
+ width: 16; height: 16
+ radius: 2
+ color: control.up.pressed ? settingsDialog.accentColor : "transparent"
+ Text {
+ text: "+"
+ anchors.centerIn: parent
+ color: settingsDialog.textColor
}
}
+ }
- // Cache Size
- Label { text: "Cache Size (GB):" }
- TextField {
- id: cacheSizeField
- Layout.fillWidth: true
-
- Component.onCompleted: {
- text = settingsDialog.cacheSize.toFixed(1)
- }
-
- onEditingFinished: {
- var value = parseFloat(text)
- if (!isNaN(value) && value >= 0.5 && value <= 16) {
- settingsDialog.cacheSize = value
- text = value.toFixed(1) // Format it
- } else {
- // Invalid input, reset to current value
- text = settingsDialog.cacheSize.toFixed(1)
- }
+ down.indicator: Item {
+ x: 0
+ height: parent.height
+ width: 20
+ Rectangle {
+ anchors.centerIn: parent
+ width: 16; height: 16
+ radius: 2
+ color: control.down.pressed ? settingsDialog.accentColor : "transparent"
+ Text {
+ text: "-"
+ anchors.centerIn: parent
+ color: settingsDialog.textColor
}
}
- Label {
- id: cacheUsageLabel
- text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB"
- color: "#1013e6"
- }
+ }
- // Prefetch Radius
- Label { text: "Prefetch Radius:" }
- SpinBox {
- id: prefetchRadiusSpinBox
- from: 1
- to: 20
- value: settingsDialog.prefetchRadius
- onValueChanged: settingsDialog.prefetchRadius = value
- }
- Label {} // Placeholder
-
- // Theme
- Label { text: "Theme:" }
- ComboBox {
- id: themeComboBox
- model: ["Dark", "Light"]
- currentIndex: settingsDialog.theme
- onCurrentIndexChanged: settingsDialog.theme = currentIndex
- }
- Label {} // Placeholder
+ background: Rectangle {
+ implicitWidth: 100
+ color: "transparent"
+ border.color: settingsDialog.controlBorder
+ border.width: 1
+ radius: 4
+ }
+ }
+ }
+
+ // State
+ property int currentTab: 0
+
+ // Component for Tab Button
+ Component {
+ id: tabButton
+ Rectangle {
+ property string text
+ property int index
+
+ anchors.fill: parent
+ color: "transparent"
+
+ Rectangle {
+ anchors.bottom: parent.bottom
+ width: parent.width
+ height: 2
+ color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "transparent"
+ Behavior on color { ColorAnimation { duration: 200 } }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: parent.text
+ color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "#80ffffff"
+ font.bold: settingsDialog.currentTab === index
+ font.pixelSize: 14
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: settingsDialog.currentTab = index
+ }
+ }
+ }
- // Default Directory
- Label { text: "Default Image Directory:" }
- TextField {
- id: defaultDirectoryField
+ // Main Layout container
+ ColumnLayout {
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: bottomBar.top
+ spacing: 0
+
+ // --- Custom Tab Bar ---
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 50
+ color: "#1e1e1e"
+ z: 10
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 20
+ anchors.bottomMargin: 0
+ spacing: 20
+
+ Loader {
Layout.fillWidth: true
- text: settingsDialog.defaultDirectory
- onTextChanged: settingsDialog.defaultDirectory = text
+ Layout.fillHeight: true
+ sourceComponent: tabButton
+ onLoaded: { item.text = "General"; item.index = 0 }
}
- Button {
- text: "Browse..."
- onClicked: {
- var path = uiState.open_directory_dialog()
- if (path) defaultDirectoryField.text = path
- }
- }
-
- // Optimize For
- Label { text: "Optimize For:" }
- ComboBox {
- id: optimizeForComboBox
- model: ["speed", "quality"]
- currentIndex: model.indexOf(settingsDialog.optimizeFor)
- onCurrentIndexChanged: settingsDialog.optimizeFor = model[currentIndex]
+ Loader {
Layout.fillWidth: true
+ Layout.fillHeight: true
+ sourceComponent: tabButton
+ onLoaded: { item.text = "Auto Adjustments"; item.index = 1 }
}
- Label {} // Placeholder
+ }
+
+ // Bottom border for tab bar
+ Rectangle {
+ anchors.bottom: parent.bottom
+ width: parent.width
+ height: 1
+ color: "#20ffffff"
+ }
+ }
- // Auto Levels Clip Threshold
- Label { text: "Auto Levels Clip %:" }
- TextField {
- id: autoLevelThresholdField
- Layout.fillWidth: true
-
- onEditingFinished: {
- var value = parseFloat(text)
- if (!isNaN(value) && value >= 0.0 && value <= 10.0) {
- settingsDialog.autoLevelClippingThreshold = value
- text = value.toFixed(4)
- } else {
- text = settingsDialog.autoLevelClippingThreshold.toFixed(4)
+ // --- Content Stack ---
+ StackLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ currentIndex: settingsDialog.currentTab
+
+ // --- TAB 1: GENERAL ---
+ Item {
+ ScrollView {
+ anchors.fill: parent
+ anchors.margins: 20
+ clip: true
+ contentWidth: availableWidth
+
+ ColumnLayout {
+ width: parent.width
+ spacing: 15
+
+ Loader {
+ sourceComponent: sectionHeader
+ onLoaded: item.text = "General Settings"
}
- }
- }
- Label {} // Placeholder
- // Auto Levels Strength
- Label { text: "Auto Levels Strength:" }
- RowLayout {
- Layout.fillWidth: true
- Slider {
- id: autoLevelStrengthSlider
- from: 0.0
- to: 1.0
- stepSize: 0.05
- value: settingsDialog.autoLevelStrength
- onValueChanged: settingsDialog.autoLevelStrength = value
- enabled: !autoLevelStrengthAutoCheckBox.checked
- Layout.fillWidth: true
- opacity: enabled ? 1.0 : 0.5
- }
- CheckBox {
- id: autoLevelStrengthAutoCheckBox
- text: "Auto"
- checked: settingsDialog.autoLevelStrengthAuto
- onCheckedChanged: settingsDialog.autoLevelStrengthAuto = checked
+ // Helicon Path
+ Label { text: "Helicon Focus Path"; color: "#aaaaaa"; font.pixelSize: 12 }
+ RowLayout {
+ Layout.fillWidth: true
+ Loader {
+ id: heliconField
+ sourceComponent: styledTextField
+ Layout.fillWidth: true
+ onLoaded: {
+ // Text is set once in onVisibleChanged
+ item.text = settingsDialog.heliconPath
+ item.textEdited.connect(function() { settingsDialog.heliconPath = item.text })
+ }
+ }
+ Button {
+ text: "Browse"
+ flat: true
+ onClicked: {
+ var path = uiState.open_file_dialog()
+ if (path) {
+ settingsDialog.heliconPath = path
+ if (heliconField.item) heliconField.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.heliconPath)
+ }
+ }
+
+ // Photoshop Path
+ Label { text: "Photoshop Path"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 }
+ RowLayout {
+ Layout.fillWidth: true
+ Loader {
+ id: photoshopField
+ sourceComponent: styledTextField
+ Layout.fillWidth: true
+ onLoaded: {
+ // Text is set once in onVisibleChanged
+ item.text = settingsDialog.photoshopPath
+ item.textEdited.connect(function() { settingsDialog.photoshopPath = item.text })
+ }
+ }
+ Button {
+ text: "Browse"
+ flat: true
+ onClicked: {
+ var path = uiState.open_file_dialog()
+ if (path) {
+ settingsDialog.photoshopPath = path
+ if (photoshopField.item) photoshopField.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.photoshopPath)
+ }
+ }
+
+ // Default Directory
+ Label { text: "Default Image Directory"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 }
+ RowLayout {
+ Layout.fillWidth: true
+ Loader {
+ id: defaultDirField
+ sourceComponent: styledTextField
+ Layout.fillWidth: true
+ onLoaded: {
+ // Text is set once in onVisibleChanged
+ item.text = settingsDialog.defaultDirectory
+ item.textEdited.connect(function() { settingsDialog.defaultDirectory = item.text })
+ }
+ }
+ Button {
+ text: "Browse"
+ flat: true
+ onClicked: {
+ var path = uiState.open_directory_dialog()
+ if (path) {
+ settingsDialog.defaultDirectory = path
+ if (defaultDirField.item) defaultDirField.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 }
+ }
+ }
+
+ Loader { sourceComponent: sectionSeparator }
+
+ // Grid for Cache/Theme/Etc
+ GridLayout {
+ columns: 2
+ columnSpacing: 20
+ rowSpacing: 15
+ Layout.fillWidth: true
+ Layout.topMargin: 5
+
+ // Cache
+ Label {
+ text: "Cache Size (GB)"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: cacheSizeHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: cacheSizeHover.containsMouse
+ ToolTip.text: "Decoded images are cached in RAM for faster browsing. Higher values allow more images to be kept in memory, reducing re-decode times. Lower values use less RAM. Recommended: 2-8 GB depending on available memory."
+ }
+ RowLayout {
+ Loader {
+ id: cacheSizeField
+ sourceComponent: styledTextField
+ Layout.preferredWidth: 80
+ onLoaded: {
+ // Text is set once in onVisibleChanged
+ item.text = settingsDialog.cacheSize.toFixed(1)
+ item.editingFinished.connect(function() {
+ var value = parseFloat(item.text)
+ if (!isNaN(value) && value >= 0.5 && value <= 16) {
+ settingsDialog.cacheSize = value
+ // Reformat to show consistent precision
+ item.text = settingsDialog.cacheSize.toFixed(1)
+ } else {
+ // Reset to valid value if invalid input
+ item.text = settingsDialog.cacheSize.toFixed(1)
+ }
+ })
+ }
+ }
+ Label {
+ text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB"
+ color: settingsDialog.accentColorHover
+ font.pixelSize: 11
+ }
+ }
+
+ // Prefetch
+ Label {
+ text: "Prefetch Radius"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: prefetchHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: prefetchHover.containsMouse
+ ToolTip.text: "Number of images around the current image to pre-load in the background. Higher values make browsing smoother but use more CPU/RAM. Lower values reduce resource usage. Recommended: 4-8 for smooth navigation."
+ }
+ Loader {
+ sourceComponent: styledSpinBox
+ onLoaded: {
+ item.from = 1; item.to = 20
+ item.value = settingsDialog.prefetchRadius
+ item.valueChanged.connect(function() { settingsDialog.prefetchRadius = item.value })
+ }
+ }
+
+ // Optimize For
+ Label {
+ text: "Optimize For"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: optimizeHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: optimizeHover.containsMouse
+ ToolTip.text: "Speed: Faster JPEG decoding using hardware acceleration (may have slight quality loss). Quality: Slower but pixel-perfect decoding. Choose Speed for general browsing, Quality for critical image inspection."
+ }
+ ComboBox {
+ model: ["speed", "quality"]
+ currentIndex: Math.max(0, model.indexOf(settingsDialog.optimizeFor))
+ onActivated: settingsDialog.optimizeFor = model[currentIndex]
+ Layout.preferredWidth: 150
+ delegate: ItemDelegate {
+ width: parent.width
+ contentItem: Text { text: modelData; color: settingsDialog.textColor; font: parent.font; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter }
+ background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" }
+ }
+ contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 }
+ background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 }
+ }
+
+ // Theme
+ Label { text: "Theme"; color: settingsDialog.textColor }
+ ComboBox {
+ model: ["Dark", "Light"]
+ currentIndex: settingsDialog.theme
+ onActivated: settingsDialog.theme = currentIndex
+ Layout.preferredWidth: 150
+ delegate: ItemDelegate {
+ width: parent.width
+ contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter }
+ background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" }
+ }
+ contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 }
+ background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 }
+ }
+ }
+
+ Item { Layout.fillHeight: true } // Spacer
}
}
- Label { text: Math.round(settingsDialog.autoLevelStrength * 100) + "%" }
}
+
+ // --- TAB 2: AUTO ADJUSTMENTS ---
+ Item {
+ ScrollView {
+ anchors.fill: parent
+ anchors.margins: 20
+ clip: true
+ contentWidth: availableWidth
- GridLayout {
- columns: 3
-
- // --- Auto White Balance ---
- MouseArea {
- width: awbModeLabel.implicitWidth
- height: awbModeLabel.implicitHeight
- hoverEnabled: true
- Layout.topMargin: 10
- Label {
- id: awbModeLabel
- text: "Auto WB Mode:"
- }
- ToolTip.visible: containsMouse
- ToolTip.text: "Choose the algorithm for Auto White Balance.\n'lab': Uses Lab color space (recommended).\n'rgb': Uses simple Grey World assumption."
- }
- ComboBox {
- id: awbModeComboBox
- model: ["lab", "rgb"]
- currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode))
- onCurrentIndexChanged: settingsDialog.awbMode = model[currentIndex]
- Layout.topMargin: 10
- }
- Label {
- Layout.topMargin: 10
- }
+ ColumnLayout {
+ width: parent.width
+ spacing: 15
- MouseArea {
- width: awbStrengthLabel.implicitWidth
- height: awbStrengthLabel.implicitHeight
- hoverEnabled: true
- Label {
- id: awbStrengthLabel
- text: "Auto WB Strength:"
- }
- ToolTip.visible: containsMouse
- ToolTip.text: "How strongly to apply the calculated white balance correction (0.0 - 1.0)."
- }
- Slider {
- id: awbStrengthSlider
- from: 0.3
- to: 1.0
- value: settingsDialog.awbStrength
- onValueChanged: settingsDialog.awbStrength = value
- }
- Label { text: (awbStrengthSlider.value * 100).toFixed(0) + "%" }
-
- MouseArea {
- width: awbWarmBiasLabel.implicitWidth
- height: awbWarmBiasLabel.implicitHeight
- hoverEnabled: true
- Label {
- id: awbWarmBiasLabel
- text: "Auto WB Warm Bias:"
- }
- ToolTip.visible: containsMouse
- ToolTip.text: "Adjusts the target Yellow/Blue balance.\nPositive values make the result warmer (more yellow).\nNegative values make it cooler (more blue)."
- }
- SpinBox {
- id: awbWarmBiasSpinBox
- from: -50
- to: 50
- value: settingsDialog.awbWarmBias
- editable: true
- onValueChanged: settingsDialog.awbWarmBias = value
- }
- Label {} // Placeholder
-
- MouseArea {
- width: awbTintBiasLabel.implicitWidth
- height: awbTintBiasLabel.implicitHeight
- hoverEnabled: true
- Label {
- id: awbTintBiasLabel
- text: "Auto WB Tint Bias:"
- }
- ToolTip.visible: containsMouse
- ToolTip.text: "Adjusts the target Magenta/Green balance.\nPositive values add magenta tint.\nNegative values add green tint."
- }
- SpinBox {
- id: awbTintBiasSpinBox
- from: -50
- to: 50
- value: settingsDialog.awbTintBias
- editable: true
- onValueChanged: settingsDialog.awbTintBias = value
- }
- Label {} // Placeholder
-
- // --- Advanced AWB Settings ---
- CheckBox {
- id: advancedAwbCheckBox
- text: "Advanced Settings"
- checked: false
- Layout.columnSpan: 3
- hoverEnabled: true
- ToolTip.visible: hovered
- ToolTip.text: "Configure thresholds for pixel selection in AWB calculation."
- }
+ // --- Auto Levels ---
+ Loader {
+ sourceComponent: sectionHeader
+ onLoaded: item.text = "Auto Levels"
+ }
- GridLayout {
- visible: advancedAwbCheckBox.checked
- columns: 3
- Layout.columnSpan: 3
- Layout.fillWidth: true
-
- MouseArea {
- width: lumaLowerLabel.implicitWidth
- height: lumaLowerLabel.implicitHeight
- hoverEnabled: true
- Label {
- id: lumaLowerLabel
- text: "Luma Lower Bound:"
+ GridLayout {
+ columns: 2
+ columnSpacing: 20
+ rowSpacing: 10
+ Layout.fillWidth: true
+
+ Label {
+ text: "Clip Threshold %"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: clipThresholdHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ 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%"
+ }
+ Loader {
+ sourceComponent: styledTextField
+ Layout.preferredWidth: 80
+ onLoaded: {
+ item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4)
+ item.editingFinished.connect(function() {
+ var value = parseFloat(item.text)
+ if (!isNaN(value) && value >= 0.0 && value <= 10.0) settingsDialog.autoLevelClippingThreshold = value
+ item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4)
+ })
+ }
+ Binding {
+ target: parent.item
+ property: "text"
+ value: settingsDialog.autoLevelClippingThreshold.toFixed(4)
+ when: parent.item && !parent.item.activeFocus
+ }
+ }
+
+ Label {
+ text: "Strength"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: autoLevelStrengthHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: autoLevelStrengthHover.containsMouse
+ ToolTip.text: "How much of the auto-levels correction to apply. 1.0 applies the full mathematical correction, lower values blend with the original for a subtler effect. The 'Auto' checkbox enables automatic strength reduction to avoid excessive clipping."
+ }
+ RowLayout {
+ Layout.fillWidth: true
+ Loader {
+ sourceComponent: styledSlider
+ Layout.fillWidth: true
+ onLoaded: {
+ item.from = 0.0; item.to = 1.0; item.stepSize = 0.05
+ item.value = settingsDialog.autoLevelStrength
+ item.valueChanged.connect(function() { settingsDialog.autoLevelStrength = item.value })
+ item.enabled = Qt.binding(function() { return !autoLvlAuto.checked })
+ item.opacity = Qt.binding(function() { return (!autoLvlAuto.checked) ? 1.0 : 0.5 })
+ }
+ Binding {
+ target: parent.item
+ property: "value"
+ value: settingsDialog.autoLevelStrength
+ when: parent.item && !parent.item.pressed
+ }
+ }
+ CheckBox {
+ id: autoLvlAuto
+ text: "Auto"
+ checked: settingsDialog.autoLevelStrengthAuto
+ onCheckedChanged: settingsDialog.autoLevelStrengthAuto = checked
+ contentItem: Text { text: parent.text; color: settingsDialog.textColor; leftPadding: parent.indicator.width + parent.spacing; verticalAlignment: Text.AlignVCenter }
+ indicator: Rectangle {
+ implicitWidth: 18; implicitHeight: 18
+ x: parent.leftPadding; y: parent.height / 2 - height / 2
+ radius: 3
+ border.color: settingsDialog.accentColor
+ color: parent.checked ? settingsDialog.accentColor : "transparent"
+ Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: parent.parent.checked; font.bold: true }
+ }
+ }
+ }
}
- ToolTip.visible: containsMouse
- ToolTip.text: "Ignore pixels darker than this brightness (0-255) when calculating AWB."
- }
- SpinBox {
- from: 0
- to: 255
- value: settingsDialog.awbLumaLowerBound
- editable: true
- onValueChanged: settingsDialog.awbLumaLowerBound = value
- }
- Label {}
-
- MouseArea {
- width: lumaUpperLabel.implicitWidth
- height: lumaUpperLabel.implicitHeight
- hoverEnabled: true
- Label {
- id: lumaUpperLabel
- text: "Luma Upper Bound:"
+
+ Loader { sourceComponent: sectionSeparator }
+
+ // --- Auto White Balance ---
+ Loader {
+ sourceComponent: sectionHeader
+ onLoaded: item.text = "Auto White Balance"
}
- ToolTip.visible: containsMouse
- ToolTip.text: "Ignore pixels brighter than this brightness (0-255) when calculating AWB."
- }
- SpinBox {
- from: 0
- to: 255
- value: settingsDialog.awbLumaUpperBound
- editable: true
- onValueChanged: settingsDialog.awbLumaUpperBound = value
- }
- Label {}
-
- MouseArea {
- width: rgbLowerLabel.implicitWidth
- height: rgbLowerLabel.implicitHeight
- hoverEnabled: true
- Label {
- id: rgbLowerLabel
- text: "RGB Lower Bound:"
+
+ GridLayout {
+ columns: 2
+ columnSpacing: 20
+ rowSpacing: 15
+ Layout.fillWidth: true
+
+ // AWB Mode
+ Label {
+ text: "Algorithm"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: awbAlgorithmHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: awbAlgorithmHover.containsMouse
+ ToolTip.text: "Algorithm for auto white balance. 'lab' analyzes in LAB color space for perceptually uniform results. 'rgb' works directly in RGB space. Most users should use 'lab'."
+ }
+ ComboBox {
+ model: ["lab", "rgb"]
+ currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode))
+ onActivated: settingsDialog.awbMode = model[currentIndex]
+ Layout.preferredWidth: 150
+ delegate: ItemDelegate {
+ width: parent.width
+ contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter }
+ background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" }
+ }
+ contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 }
+ background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 }
+ }
+
+ // Strength
+ Label {
+ text: "Strength (" + (awbStrSlider.item ? Math.round(awbStrSlider.item.value * 100) : 0) + "%)"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: awbStrengthHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: awbStrengthHover.containsMouse
+ ToolTip.text: "How aggressively to apply the auto white balance correction. 100% applies full correction, lower values blend with original. Range: 30-100%. Recommended: 70%"
+ }
+ Loader {
+ id: awbStrSlider
+ sourceComponent: styledSlider
+ Layout.fillWidth: true
+ onLoaded: {
+ item.from = 0.3; item.to = 1.0
+ item.value = settingsDialog.awbStrength
+ item.valueChanged.connect(function() { settingsDialog.awbStrength = item.value })
+ }
+ Binding {
+ target: parent.item
+ property: "value"
+ value: settingsDialog.awbStrength
+ when: parent.item && !parent.item.pressed
+ }
+ }
+
+ // Warm Bias
+ Label {
+ text: "Warm Bias (Yel/Blu)"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: warmBiasHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: warmBiasHover.containsMouse
+ 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 {
+ sourceComponent: styledSpinBox
+ onLoaded: {
+ item.from = -50; item.to = 50
+ item.value = settingsDialog.awbWarmBias
+ item.valueChanged.connect(function() { settingsDialog.awbWarmBias = item.value })
+ }
+ Binding {
+ target: parent.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'.
+ }
+ }
+
+ // Tint Bias
+ Label {
+ text: "Tint Bias (Mag/Grn)"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: tintBiasHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: tintBiasHover.containsMouse
+ 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 {
+ sourceComponent: styledSpinBox
+ onLoaded: {
+ item.from = -50; item.to = 50
+ item.value = settingsDialog.awbTintBias
+ item.valueChanged.connect(function() { settingsDialog.awbTintBias = item.value })
+ }
+ Binding {
+ target: parent.item
+ property: "value"
+ value: settingsDialog.awbTintBias
+ when: parent.item
+ }
+ }
}
- ToolTip.visible: containsMouse
- ToolTip.text: "Ignore pixels where any channel is below this value (0-255)."
- }
- SpinBox {
- from: 0
- to: 255
- value: settingsDialog.awbRgbLowerBound
- editable: true
- onValueChanged: settingsDialog.awbRgbLowerBound = value
- }
- Label {}
-
- MouseArea {
- width: rgbUpperLabel.implicitWidth
- height: rgbUpperLabel.implicitHeight
- hoverEnabled: true
- Label {
- id: rgbUpperLabel
- text: "RGB Upper Bound:"
+
+ Loader { sourceComponent: sectionSeparator }
+
+ Loader {
+ sourceComponent: sectionHeader
+ onLoaded: item.text = "Advanced Thresholds"
}
- ToolTip.visible: containsMouse
- ToolTip.text: "Ignore pixels where any channel is above this value (0-255)."
- }
- SpinBox {
- from: 0
- to: 255
- value: settingsDialog.awbRgbUpperBound
- editable: true
- onValueChanged: settingsDialog.awbRgbUpperBound = value
+
+ GridLayout {
+ columns: 2
+ columnSpacing: 20
+ rowSpacing: 10
+ Layout.fillWidth: true
+
+ Label {
+ text: "Luma Lower"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: lumaLowerHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: lumaLowerHover.containsMouse
+ 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 {
+ 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
+ property: "value"
+ value: settingsDialog.awbLumaLowerBound
+ when: parent.item
+ }
+ }
+
+ Label {
+ text: "Luma Upper"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: lumaUpperHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: lumaUpperHover.containsMouse
+ 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 {
+ 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
+ property: "value"
+ value: settingsDialog.awbLumaUpperBound
+ when: parent.item
+ }
+ }
+
+ Label {
+ text: "RGB Lower"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: rgbLowerHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: rgbLowerHover.containsMouse
+ 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 {
+ 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
+ property: "value"
+ value: settingsDialog.awbRgbLowerBound
+ when: parent.item
+ }
+ }
+
+ Label {
+ text: "RGB Upper"
+ color: settingsDialog.textColor
+
+ MouseArea {
+ id: rgbUpperHover
+ anchors.fill: parent
+ hoverEnabled: true
+ }
+
+ ToolTip.visible: rgbUpperHover.containsMouse
+ 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 {
+ 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
+ property: "value"
+ value: settingsDialog.awbRgbUpperBound
+ when: parent.item
+ }
+ }
+ }
+
+ Item { Layout.fillHeight: true } // Spacer
}
- Label {}
}
}
}
}
- // Poll cache usage periodically while the dialog is open
+ // Bottom Action Bar
+ Rectangle {
+ id: bottomBar
+ anchors.bottom: parent.bottom
+ anchors.left: parent.left
+ anchors.right: parent.right
+ height: 70
+ color: "#1e1e1e" // matches background
+ // Gradient separator
+ Rectangle { width: parent.width; height: 1; color: "#20ffffff"; anchors.top: parent.top }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 20
+ spacing: 15
+
+ Item { Layout.fillWidth: true } // Spacer left
+
+ Button {
+ text: "Cancel"
+ Layout.preferredWidth: 100
+ onClicked: settingsDialog.visible = false
+
+ contentItem: Text {
+ text: parent.text
+ font: parent.font
+ color: settingsDialog.textColor
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+ background: Rectangle {
+ color: parent.pressed ? "#40ffffff" : "#20ffffff"
+ radius: 4
+ border.color: parent.hovered ? "#60ffffff" : "transparent"
+ }
+ }
+
+ Button {
+ text: "Save"
+ Layout.preferredWidth: 100
+ highlighted: true
+ onClicked: settingsDialog.saveSettings()
+
+ contentItem: Text {
+ text: parent.text
+ font: parent.font
+ color: "white"
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+ background: Rectangle {
+ color: parent.pressed ? Qt.darker(settingsDialog.accentColor, 1.1) : settingsDialog.accentColor
+ radius: 4
+ }
+ }
+ }
+ }
+
Timer {
id: cacheUsageTimer
interval: 1000
diff --git a/faststack/tests/test_auto_levels.py b/faststack/tests/test_auto_levels.py
new file mode 100644
index 0000000..a6ed3b9
--- /dev/null
+++ b/faststack/tests/test_auto_levels.py
@@ -0,0 +1,137 @@
+
+import pytest
+import numpy as np
+from PIL import Image
+from faststack.imaging.editor import ImageEditor
+
+
+def test_auto_levels_pins_highlights_if_clipped():
+ editor = ImageEditor()
+ # 10x10 image
+ w, h = 10, 10
+ arr = np.zeros((h, w, 3), dtype=np.uint8)
+ arr[:] = 100
+
+ # Clip Blue: Set last pixel to 255
+ arr[9, 9, 2] = 255
+
+ img = Image.fromarray(arr, 'RGB')
+ editor.original_image = img
+ editor._preview_image = img
+
+ # Use threshold 0.0 to make p_low deterministic (min value)
+ # This prevents fragility with per-channel percentiles on small arrays
+ blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0)
+
+ # 1 pixel of 255 in 100 is 1%. Eps (from threshold 0.0) would be 0.0.
+ # Actually logic is eps = min(threshold, 0.01). If threshold 0.0, eps=0.0.
+ # 1% > 0.0% -> Pins.
+
+ assert p_high == 255.0
+ assert whites == 0.0
+
+ # p_low should be the strict minimum (100)
+ assert p_low == 100.0
+
+def test_auto_levels_pins_shadows_if_clipped():
+ editor = ImageEditor()
+ w, h = 10, 10
+ arr = np.zeros((h, w, 3), dtype=np.uint8)
+ arr[:] = 100
+
+ # Clip Red shadow: 1 pixel at 0.
+ arr[0, 0, 0] = 0
+
+ img = Image.fromarray(arr, 'RGB')
+ editor.original_image = img
+ editor._preview_image = img
+
+ # Threshold 0.0 -> eps=0.0. 1% detected > 0.0 -> Pins.
+ blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0)
+
+ assert p_low == 0.0
+ assert blacks == 0.0
+
+ # Whites should be normal (max is 100)
+ assert p_high == 100.0
+ assert whites == (255.0 - 100.0) / 40.0
+
+def test_auto_levels_tiny_hot_pixel_ignored():
+ """
+ Verify that a very small number of clipped pixels (below eps check)
+ does NOT trigger pinning, and does NOT get picked up by percentile
+ if strictly below the threshold.
+ """
+ editor = ImageEditor()
+ # 200x200 = 40,000 pixels
+ w, h = 200, 200
+ arr = np.zeros((h, w, 3), dtype=np.uint8)
+
+ # Base: 150
+ arr[:] = 150
+
+ # Set top ~2.5% pixels to 200 (1000 pixels)
+ # This ensures the 99.9th percentile lands on 200, not 150.
+ # Flattening for easier assignment
+ flat = arr.reshape(-1, 3)
+ flat[0:1000, :] = 200
+ arr = flat.reshape(h, w, 3)
+
+ # Add ONE hot pixel at 255 in Red channel
+ arr[0, 0, 0] = 255
+
+ img = Image.fromarray(arr, 'RGB')
+ editor.original_image = img
+ editor._preview_image = img
+
+ # Threshold 0.1%. Eps = 0.01%.
+ # 1 pixel / 40000 = 0.0025%.
+ # 0.0025% < 0.01%. Should NOT pin.
+
+ blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1)
+
+ # p_high should be 200 (from the 200-level plateau), ignoring the 255.
+ assert p_high == 200.0
+ assert p_high != 255.0 # Check explicitly not pinned
+ assert whites > 0.0
+
+def test_auto_levels_degenerate_image():
+ editor = ImageEditor()
+ w, h = 10, 10
+ arr = np.zeros((h, w, 3), dtype=np.uint8)
+ arr[:] = 128
+
+ img = Image.fromarray(arr, 'RGB')
+ editor.original_image = img
+ editor._preview_image = img
+
+ blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1)
+
+ assert p_high == 128.0
+ assert p_low == 128.0
+ assert blacks == 0.0
+ assert whites == 0.0
+
+def test_auto_levels_normal_range():
+ editor = ImageEditor()
+ w, h = 10, 10
+ arr = np.zeros((h, w, 3), dtype=np.uint8)
+ arr[:] = 128
+ arr[0, 0, :] = 50 # Low
+ arr[9, 9, :] = 200 # High
+
+ img = Image.fromarray(arr, 'RGB')
+ editor.original_image = img
+ editor._preview_image = img
+
+ blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0)
+
+ assert p_high == 200.0
+ assert p_low == 50.0
+
+ assert p_high != 255.0 # Not pinned
+ assert p_low != 0.0 # Not pinned
+
+ assert whites == (255.0 - 200.0) / 40.0
+ assert blacks == -50.0 / 40.0
+
diff --git a/faststack/tests/test_cache.py b/faststack/tests/test_cache.py
index f36d4dc..fdc8652 100644
--- a/faststack/tests/test_cache.py
+++ b/faststack/tests/test_cache.py
@@ -15,7 +15,7 @@ def __sizeof__(self) -> int:
def test_cache_init():
"""Tests cache initialization."""
cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__())
- assert cache.maxsize == 1000
+ assert cache.max_bytes == 1000
assert cache.currsize == 0
def test_cache_add_items():
diff --git a/faststack/tests/test_new_features.py b/faststack/tests/test_new_features.py
index a2ddcc3..eec4363 100644
--- a/faststack/tests/test_new_features.py
+++ b/faststack/tests/test_new_features.py
@@ -21,17 +21,24 @@ def test_auto_levels_strength(self):
self.editor.original_image = img
self.editor._preview_image = img
- # Calculate auto levels
- blacks, whites = self.editor.auto_levels(0.1)
+ # Calculate auto levels - now returns (blacks, whites, p_low, p_high)
+ blacks, whites, p_low, p_high = self.editor.auto_levels(0.1)
# With range [50, 200], we expect:
+ # p_low should be around 50, p_high around 200 (with 0.1% percentile)
# blacks approx -50/40 = -1.25
- # whites approx (200-255)/40 = -1.375
+ # whites approx (255-200)/40 = 1.375
self.assertNotEqual(blacks, 0.0)
self.assertNotEqual(whites, 0.0)
self.assertLess(blacks, 0.0)
self.assertGreater(whites, 0.0)
+ # Verify percentile values are reasonable
+ self.assertGreater(p_low, 45) # Should be close to 50
+ self.assertLess(p_low, 55)
+ self.assertGreater(p_high, 195) # Should be close to 200
+ self.assertLess(p_high, 205)
+
# Mock strength application matching app.py logic
strength = 0.5
b_scaled = blacks * strength
@@ -85,5 +92,150 @@ def test_straighten_angle(self):
print(f"Original size: {self.img.size}, Rotated size: {res.size}")
self.assertNotEqual(res.size, self.img.size)
+ def test_auto_levels_stretch_capping(self):
+ """
+ Regression test: Verify that auto-strength uses stretch-factor capping
+ to prevent insane levels on low-dynamic-range images.
+
+ Tests:
+ 1. Reasonable dynamic range: should use full strength (strength=1.0)
+ 2. Low dynamic range: should cap stretch at 4x maximum
+ 3. Very low dynamic range: should set strength=0
+ """
+ threshold_percent = 0.1
+
+ # Test case 1: Reasonable dynamic range (50-200, range=150)
+ # Expected: stretch = 255/150 = 1.7x (< 4x cap) => strength = 1.0
+ arr_reasonable = np.linspace(50, 200, 10000, dtype=np.uint8).reshape(100, 100)
+ img_reasonable = Image.fromarray(arr_reasonable)
+ self.editor.original_image = img_reasonable
+ self.editor._preview_image = img_reasonable
+
+ blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent)
+
+ # Calculate what strength should be based on stretch factor
+ dynamic_range = p_high - p_low
+ stretch_full = 255.0 / dynamic_range
+ STRETCH_CAP = 4.0
+
+ if stretch_full <= STRETCH_CAP:
+ expected_strength = 1.0
+ else:
+ expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0)
+
+ print(f"Reasonable range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, "
+ f"stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}")
+
+ # For reasonable range, should use full strength
+ self.assertAlmostEqual(expected_strength, 1.0, places=2)
+
+ # Test case 2: Low dynamic range (100-140, range=40)
+ # Expected: stretch = 255/40 = 6.375x (> 4x cap) => strength = 3/5.375 ≈ 0.558
+ arr_low_range = np.clip(np.linspace(100, 140, 10000, dtype=np.uint8), 100, 140).reshape(100, 100)
+ img_low_range = Image.fromarray(arr_low_range)
+ self.editor.original_image = img_low_range
+ self.editor._preview_image = img_low_range
+
+ blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent)
+
+ dynamic_range = p_high - p_low
+ stretch_full = 255.0 / dynamic_range if dynamic_range >= 1.0 else 255.0
+
+ if stretch_full <= STRETCH_CAP:
+ expected_strength = 1.0
+ else:
+ expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0)
+
+ print(f"Low range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}")
+
+ # Stretch should exceed cap, strength should be reduced
+ self.assertGreater(stretch_full, STRETCH_CAP)
+ self.assertLess(expected_strength, 1.0)
+ self.assertGreater(expected_strength, 0.3) # Should still be reasonable
+
+ # Test case 3: Very low dynamic range (120-121, range≈1)
+ # Expected: strength = 0 (degenerate case)
+ arr_flat = np.full((100, 100), 120, dtype=np.uint8)
+ # Add tiny variation to avoid completely flat
+ arr_flat[0, 0] = 119
+ arr_flat[99, 99] = 121
+ img_flat = Image.fromarray(arr_flat)
+ self.editor.original_image = img_flat
+ self.editor._preview_image = img_flat
+
+ blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent)
+
+ dynamic_range = p_high - p_low
+
+ print(f"Flat image: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}")
+
+ # For very low range, should be near 0 or exactly 0
+ self.assertLess(dynamic_range, 3.0)
+
+ def test_auto_levels_clipping_tolerance(self):
+ """
+ Regression test: Verify that auto-levels respects the threshold setting
+ and doesn't introduce excessive clipping beyond the configured tolerance.
+
+ Uses deterministic synthetic images to verify clipping stays within bounds.
+ """
+ threshold_percent = 0.1
+
+ # Create a deterministic image with known distribution
+ # Use a beta distribution to create realistic luminance distribution
+ # Beta(2, 5) gives a left-skewed distribution (more shadows, fewer highlights)
+ np.random.seed(42) # Deterministic
+ beta_samples = np.random.beta(2, 5, size=10000)
+ arr = (beta_samples * 255).astype(np.uint8).reshape(100, 100)
+ img = Image.fromarray(arr)
+
+ self.editor.original_image = img
+ self.editor._preview_image = img
+
+ blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent)
+
+ # Apply at full strength
+ self.editor.set_edit_param('blacks', blacks)
+ self.editor.set_edit_param('whites', whites)
+ result = self.editor._apply_edits(img.convert('RGB'))
+ result_arr = np.array(result.convert('L'))
+
+ # Count pixels at extremes
+ total_pixels = result_arr.size
+ clipped_low = np.sum(result_arr == 0)
+ clipped_high = np.sum(result_arr == 255)
+
+ pct_clipped_low = (clipped_low / total_pixels) * 100.0
+ pct_clipped_high = (clipped_high / total_pixels) * 100.0
+
+ print(f"Beta distribution: Low clip: {pct_clipped_low:.2f}%, High clip: {pct_clipped_high:.2f}%")
+
+ # Allow small tolerance for rounding and integer quantization
+ # The threshold defines the percentiles, but due to discrete pixel values
+ # and the mapping, we may end up with slightly different clipping
+ tolerance = 0.5 # 0.1% threshold + 0.5% tolerance = 0.6% max
+
+ self.assertLessEqual(pct_clipped_low, threshold_percent + tolerance,
+ f"Excessive shadow clipping: {pct_clipped_low:.2f}% > {threshold_percent + tolerance}%")
+ self.assertLessEqual(pct_clipped_high, threshold_percent + tolerance,
+ f"Excessive highlight clipping: {pct_clipped_high:.2f}% > {threshold_percent + tolerance}%")
+
+ # Verify mapping is monotonic (sanity check)
+ # Create a gradient and verify it maps monotonically
+ gradient = np.arange(256, dtype=np.uint8)
+ gradient_img = Image.fromarray(gradient.reshape(1, 256))
+ self.editor.original_image = gradient_img
+ self.editor._preview_image = gradient_img
+
+ blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent)
+ self.editor.set_edit_param('blacks', blacks)
+ self.editor.set_edit_param('whites', whites)
+ result = self.editor._apply_edits(gradient_img.convert('RGB'))
+ result_arr = np.array(result.convert('L'))[0, :]
+
+ # Check monotonicity
+ diffs = np.diff(result_arr.astype(np.int16))
+ self.assertTrue(np.all(diffs >= 0), "Mapping is not monotonic")
+
if __name__ == '__main__':
unittest.main(verbosity=2)
diff --git a/pyproject.toml b/pyproject.toml
index ad86184..8c74ffc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,7 @@ dependencies = [
"cachetools>=5.0,<6.0",
"watchdog>=4.0,<5.0",
"Pillow>=10.0,<11.0",
+ "opencv-python>=4.0,<5.0",
]
[project.optional-dependencies]
diff --git a/reproduce_config_issue.py b/reproduce_config_issue.py
new file mode 100644
index 0000000..d5b86b9
--- /dev/null
+++ b/reproduce_config_issue.py
@@ -0,0 +1,64 @@
+
+import sys
+import os
+from pathlib import Path
+import configparser
+
+# Update sys.path to include the project root
+sys.path.append(r"c:\code\faststack")
+
+# Mock logging setup to avoid creating real logs/directories
+import faststack.logging_setup
+import faststack.config
+
+def test_config_persistence():
+ print("Testing config persistence...")
+
+ # Use a temporary file for testing
+ test_config_dir = Path("c:/code/faststack/test_config_dir")
+ test_config_dir.mkdir(exist_ok=True)
+
+ # Monkeypatch get_app_data_dir to use local dir
+ faststack.config.get_app_data_dir = lambda: test_config_dir
+
+ # 1. Initialize config (should create defaults)
+ app_config = faststack.config.AppConfig()
+ print(f"Config path: {app_config.config_path}")
+
+ # Verify default
+ initial_val = app_config.get('core', 'auto_level_threshold')
+ print(f"Initial value: {initial_val}")
+ if initial_val != "0.1":
+ print("FAIL: Default value unexpected")
+
+ # 2. Modify value
+ new_val = "0.05"
+ print(f"Setting value to: {new_val}")
+ app_config.set('core', 'auto_level_threshold', new_val)
+ app_config.save()
+
+ # 3. Reload config from disk directly to verify file content
+ raw_config = configparser.ConfigParser()
+ raw_config.read(app_config.config_path)
+ file_val = raw_config.get('core', 'auto_level_threshold')
+ print(f"Value in file: {file_val}")
+
+ # 4. Re-initialize AppConfig (simulate app restart)
+ # We must clear the global instance or create a new one to force reload
+ # AppConfig.__init__ calls self.load()
+ app_config_2 = faststack.config.AppConfig()
+ loaded_val = app_config_2.get('core', 'auto_level_threshold')
+ print(f"Loaded value: {loaded_val}")
+
+ if loaded_val == new_val:
+ print("SUCCESS: Value persisted correctly")
+ else:
+ print(f"FAIL: Value did not persist. Got {loaded_val}, expected {new_val}")
+
+ # Clean up
+ if (test_config_dir / "faststack.ini").exists():
+ (test_config_dir / "faststack.ini").unlink()
+ test_config_dir.rmdir()
+
+if __name__ == "__main__":
+ test_config_persistence()
diff --git a/test_cachetools_api.py b/test_cachetools_api.py
new file mode 100644
index 0000000..b3b0c03
--- /dev/null
+++ b/test_cachetools_api.py
@@ -0,0 +1,16 @@
+"""Quick test to check cachetools.LRUCache API."""
+from cachetools import LRUCache
+
+# Create a basic LRUCache
+cache = LRUCache(maxsize=100)
+
+# Check if maxsize is a property or method
+print(f"Type of maxsize: {type(cache.maxsize)}")
+print(f"maxsize value: {cache.maxsize}")
+
+# Check if we can access the internal attribute
+if hasattr(cache, '_Cache__maxsize'):
+ print(f"Internal _Cache__maxsize: {cache._Cache__maxsize}")
+
+# List all attributes
+print(f"\nAll cache attributes: {[attr for attr in dir(cache) if not attr.startswith('_')]}")
diff --git a/test_max_bytes.py b/test_max_bytes.py
new file mode 100644
index 0000000..0f517eb
--- /dev/null
+++ b/test_max_bytes.py
@@ -0,0 +1,36 @@
+"""Quick test to verify ByteLRUCache.max_bytes works correctly."""
+from faststack.imaging.cache import ByteLRUCache
+
+class MockItem:
+ def __init__(self, size: int):
+ self._size = size
+
+ def __sizeof__(self) -> int:
+ return self._size
+
+# Test 1: Initialize cache
+cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__())
+print(f"Initial max_bytes: {cache.max_bytes}")
+assert cache.max_bytes == 1000, "Initial max_bytes should be 1000"
+
+# Test 2: Add items
+cache["a"] = MockItem(50)
+cache["b"] = MockItem(40)
+print(f"Current size: {cache.currsize}, Max bytes: {cache.max_bytes}")
+assert cache.currsize == 90, "Current size should be 90"
+
+# Test 3: Change max_bytes and verify eviction works
+cache.max_bytes = 80
+print(f"New max_bytes: {cache.max_bytes}")
+assert cache.max_bytes == 80, "max_bytes should be updated to 80"
+
+# Test 4: Add an item that triggers eviction
+cache["c"] = MockItem(50)
+print(f"After eviction - Current size: {cache.currsize}, Items: {list(cache.keys())}")
+
+# "a" should have been evicted (LRU)
+assert "a" not in cache, "Item 'a' should have been evicted"
+assert "b" in cache or "c" in cache, "At least one of 'b' or 'c' should be in cache"
+assert cache.currsize <= cache.max_bytes, f"Current size {cache.currsize} should be <= max_bytes {cache.max_bytes}"
+
+print("\n✓ All tests passed! ByteLRUCache.max_bytes works correctly.")
diff --git a/test_output.txt b/test_output.txt
new file mode 100644
index 0000000..688f167
Binary files /dev/null and b/test_output.txt differ
diff --git a/tests/test_prefetch_concurrency.py b/tests/test_prefetch_concurrency.py
new file mode 100644
index 0000000..5c0e704
--- /dev/null
+++ b/tests/test_prefetch_concurrency.py
@@ -0,0 +1,129 @@
+
+import threading
+import time
+import pytest
+import numpy as np
+from pathlib import Path
+from concurrent.futures import Future
+
+from faststack.imaging.prefetch import Prefetcher
+from faststack.models import ImageFile
+
+# Mock objects to isolate Prefetcher logic
+class MockImageFile:
+ def __init__(self, index):
+ self.path = Path(f"/mock/image_{index}.jpg")
+
+def mock_get_display_info():
+ return 1920, 1080, 1
+
+def mock_cache_put(key, value):
+ pass
+
+@pytest.fixture
+def prefetcher():
+ image_files = [MockImageFile(i) for i in range(100)]
+ # Use a small radius to force more activity
+ p = Prefetcher(image_files, mock_cache_put, prefetch_radius=5, get_display_info=mock_get_display_info, debug=False)
+
+ # Mock the internal decode method to avoid actual I/O and processing
+ # We just return a dummy result after a tiny sleep
+ def mock_decode_and_cache(*args, **kwargs):
+ time.sleep(0.0001) # fast sleep
+ return Path("/mock/image_x.jpg"), 1
+
+ p._decode_and_cache = mock_decode_and_cache
+
+ yield p
+ p.shutdown()
+
+def test_prefetch_concurrency(prefetcher):
+ """
+ Stress test for race conditions in Prefetcher.
+ Simulates concurrent navigation (update_prefetch), cancellation (cancel_all),
+ and file list updates (set_image_files).
+ """
+
+ # Configuration
+ num_loops = 5000
+ num_threads = 4
+
+ # Shared state for error tracking
+ errors = []
+
+ # Barrier to synchronize start
+ barrier = threading.Barrier(num_threads)
+
+ stop_event = threading.Event()
+
+ def worker_update():
+ try:
+ barrier.wait()
+ for i in range(num_loops):
+ if stop_event.is_set(): break
+ # Randomly jump around
+ idx = i % 100
+ prefetcher.update_prefetch(idx, is_navigation=True, direction=1)
+ except Exception as e:
+ errors.append(e)
+ stop_event.set()
+
+ def worker_cancel():
+ try:
+ barrier.wait()
+ for i in range(num_loops):
+ if stop_event.is_set(): break
+ if i % 10 == 0: # Cancel less frequently
+ prefetcher.cancel_all()
+ except Exception as e:
+ errors.append(e)
+ stop_event.set()
+
+ def worker_set_files():
+ try:
+ barrier.wait()
+ # Generate two lists to toggle between
+ list1 = [MockImageFile(i) for i in range(100)]
+ list2 = [MockImageFile(i) for i in range(50)] # Different size
+
+ for i in range(num_loops):
+ if stop_event.is_set(): break
+ if i % 100 == 0: # Reload files occasionally
+ new_list = list2 if i % 200 == 0 else list1
+ prefetcher.set_image_files(new_list)
+ except Exception as e:
+ errors.append(e)
+ stop_event.set()
+
+ # Create threads
+ threads = [
+ threading.Thread(target=worker_update),
+ threading.Thread(target=worker_update), # Two updaters
+ threading.Thread(target=worker_cancel),
+ threading.Thread(target=worker_set_files)
+ ]
+
+ # Start threads
+ for t in threads:
+ t.start()
+
+ # Wait for completion
+ for t in threads:
+ t.join()
+
+ # Assertions
+ if errors:
+ pytest.fail(f"Exceptions occurred in worker threads: {errors}")
+
+ # Verify internal consistency
+ with prefetcher._futures_lock:
+ # Check that scheduled matches generation (basic check)
+ for gen, scheduled_set in prefetcher._scheduled.items():
+ if gen > prefetcher.generation:
+ pytest.fail(f"Found scheduled set for future generation {gen} > {prefetcher.generation}")
+
+ # Check futures dict consistency
+ # It's hard to assert exact size since threads stopped at random times,
+ # but we can check if keys in futures are valid integers roughly
+ assert isinstance(prefetcher.futures, dict)
+