Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,6 @@ Logs will be saved to `~/.photosort_logs/photosort_app.log`.

* **Enhanced Search Capabilities**:
* Search by EXIF metadata (camera model, settings, date ranges)
* Search by color labels and custom tags
* Saved search presets
* **Sort/Order by Rating**: Implement functionality to sort or reorder images directly based on their assigned star ratings.
* **AI-Driven Exposure Analysis**: Introduce a feature to detect and flag images with potentially good or problematic exposure (e.g., under/overexposed).
* **Automated Best Shot Selection in Clusters**:
* Within similarity clusters, automatically suggest or select the "best" image(s).
* Criteria could include: lowest blurriness score, optimal exposure, AI composition analysis, no one with eyes close etc.
* **Advanced AI Object/Scene Detections & Grouping**:
* **Car Model Recognition**: Identify and allow grouping by specific car models in photos.
* **Face Recognition/Clustering**: Detect faces and group photos by the people present.
Expand Down
2 changes: 2 additions & 0 deletions src/ui/app_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ def load_folder(self, folder_path: str):
self.main_window.cluster_sort_combo.setCurrentIndex(0)
self.main_window.menu_manager.group_by_similarity_action.setEnabled(False)
self.main_window.menu_manager.group_by_similarity_action.setChecked(False)
self.main_window.refresh_navigation_shortcut_actions()

self.main_window.file_system_model.clear()
self.main_window.file_system_model.setColumnCount(1)
Expand Down Expand Up @@ -1047,6 +1048,7 @@ def handle_clustering_complete(self, cluster_results_dict: Dict[str, int]):
self.main_window.cluster_sort_combo.setEnabled(True)
self.main_window.menu_manager.set_cluster_sort_menu_enabled(True)
self.main_window.menu_manager.analyze_best_shots_action.setEnabled(True)
self.main_window.refresh_navigation_shortcut_actions()
if self.main_window.group_by_similarity_mode:
self.main_window._rebuild_model_view()
self.main_window.hide_loading_overlay()
Expand Down
138 changes: 137 additions & 1 deletion src/ui/helpers/navigation_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
from typing import Optional, Sequence, Iterable, Set
from collections import Counter
from typing import Callable, Optional, Sequence, Iterable, Set

# Navigation helpers extracted from MainWindow. These are UI-agnostic and operate on
# ordered path lists plus simple state flags. MainWindow is responsible for mapping
Expand Down Expand Up @@ -99,3 +100,138 @@ def is_ok(p: str) -> bool:
return None
else:
return current_path


def _iter_indices(direction: str, current_index: int, total: int):
if total <= 0:
return tuple()
if direction == "down":
start = current_index + 1 if current_index >= 0 else 0
return range(max(0, start), total)
if direction == "up":
start = current_index - 1 if current_index >= 0 else total - 1
return range(min(start, total - 1), -1, -1)
return tuple()


def find_next_rating_match(
ordered_paths: Sequence[str],
direction: str,
current_index: int,
target_rating: Optional[int],
rating_lookup: Callable[[str], Optional[int]],
skip_deleted: bool,
is_deleted: Optional[Callable[[str], bool]] = None,
) -> Optional[str]:
if target_rating is None or direction not in {"up", "down"}:
return None
total = len(ordered_paths)
if total == 0:
return None

for idx in _iter_indices(direction, current_index, total):
if idx < 0 or idx >= total:
continue
path = ordered_paths[idx]
if skip_deleted and is_deleted and is_deleted(path):
continue
rating = rating_lookup(path) if rating_lookup else None
if rating == target_rating:
return path
return None


def find_next_multi_image_cluster_head(
ordered_paths: Sequence[str],
direction: str,
current_index: int,
cluster_lookup: Callable[[str], Optional[int]],
skip_deleted: bool,
is_deleted: Optional[Callable[[str], bool]] = None,
) -> Optional[str]:
if direction not in {"up", "down"}:
return None
total = len(ordered_paths)
if total == 0:
return None
cluster_values = [
cluster_lookup(path) if cluster_lookup else None for path in ordered_paths
]
cluster_counts = Counter(cid for cid in cluster_values if cid is not None)
multi_clusters = {cid for cid, count in cluster_counts.items() if count > 1}
if not multi_clusters:
return None

current_cluster = None
if 0 <= current_index < total:
current_cluster = cluster_values[current_index]

def is_cluster_head(index: int) -> bool:
cid = cluster_values[index]
if cid not in multi_clusters:
return False
prev_index = index - 1
while prev_index >= 0:
prev_path = ordered_paths[prev_index]
prev_cid = cluster_values[prev_index]
if skip_deleted and is_deleted and is_deleted(prev_path):
prev_index -= 1
continue
return prev_cid != cid
return True

for idx in _iter_indices(direction, current_index, total):
if idx < 0 or idx >= total:
continue
path = ordered_paths[idx]
if skip_deleted and is_deleted and is_deleted(path):
continue
cid = cluster_values[idx]
if current_cluster is not None and cid == current_cluster:
continue
if is_cluster_head(idx):
return path
return None


def find_next_in_same_multi_cluster(
ordered_paths: Sequence[str],
direction: str,
current_index: int,
cluster_lookup: Callable[[str], Optional[int]],
skip_deleted: bool,
is_deleted: Optional[Callable[[str], bool]] = None,
) -> Optional[str]:
"""Move within the current multi-image cluster if possible.

Returns the next path inside the same cluster following display order,
or None if the current cluster is singleton, unknown, or you are at its edge.
"""
if direction not in {"up", "down"}:
return None
if current_index < 0 or current_index >= len(ordered_paths):
return None

current_cluster = cluster_lookup(ordered_paths[current_index])
if current_cluster is None:
return None

# Pre-compute cluster membership for quick lookups
cluster_values = [
cluster_lookup(p) if cluster_lookup else None for p in ordered_paths
]
cluster_counts = Counter(cid for cid in cluster_values if cid is not None)
if cluster_counts.get(current_cluster, 0) <= 1:
return None

step = 1 if direction == "down" else -1
idx = current_index + step
while 0 <= idx < len(ordered_paths):
if cluster_values[idx] != current_cluster:
break
path = ordered_paths[idx]
if skip_deleted and is_deleted and is_deleted(path):
idx += step
continue
return path
return None
Loading
Loading