diff --git a/gallery_app.py b/gallery_app.py new file mode 100644 index 0000000..1b74136 --- /dev/null +++ b/gallery_app.py @@ -0,0 +1,568 @@ +import os +import math +import asyncio +from typing import Optional, List, Dict, Set +from nicegui import ui, app, run +from fastapi import Response +from engine import SorterEngine + +# ========================================== +# STATE MANAGEMENT +# ========================================== +class AppState: + """Centralized application state with lazy loading.""" + + def __init__(self): + # Profile Data + self.profiles = SorterEngine.load_profiles() + self.profile_name = "Default" + if not self.profiles: + self.profiles = {"Default": {"tab5_source": "/storage", "tab5_out": "/storage"}} + + self.load_active_profile() + + # View Settings + self.page = 0 + self.page_size = 24 + self.grid_cols = 4 + self.preview_quality = 50 + + # Tagging State + self.active_cat = "Default" + self.next_index = 1 + + # Batch Settings + self.batch_mode = "Copy" + self.cleanup_mode = "Keep" + + # Data Caches + self.all_images: List[str] = [] + self.staged_data: Dict = {} + self.green_dots: Set[int] = set() + self.index_map: Dict[int, str] = {} + + # UI Containers (populated later) + self.sidebar_container = None + self.grid_container = None + self.pagination_container = None + + def load_active_profile(self): + """Load paths from active profile.""" + p_data = self.profiles.get(self.profile_name, {}) + self.source_dir = p_data.get("tab5_source", "/storage") + self.output_dir = p_data.get("tab5_out", "/storage") + + def save_current_profile(self): + """Save current paths to active profile.""" + if self.profile_name not in self.profiles: + self.profiles[self.profile_name] = {} + self.profiles[self.profile_name]["tab5_source"] = self.source_dir + self.profiles[self.profile_name]["tab5_out"] = self.output_dir + SorterEngine.save_tab_paths(self.profile_name, t5_s=self.source_dir, t5_o=self.output_dir) + ui.notify(f"Profile '{self.profile_name}' saved!", type='positive') + + def get_categories(self) -> List[str]: + """Get list of categories, ensuring active_cat exists.""" + cats = SorterEngine.get_categories() or ["Default"] + if self.active_cat not in cats: + self.active_cat = cats[0] + return cats + + @property + def total_pages(self) -> int: + """Calculate total pages.""" + return math.ceil(len(self.all_images) / self.page_size) if self.all_images else 0 + + def get_current_batch(self) -> List[str]: + """Get images for current page.""" + if not self.all_images: + return [] + start = self.page * self.page_size + return self.all_images[start : start + self.page_size] + +state = AppState() + +# ========================================== +# IMAGE SERVING API +# ========================================== + +@app.get('/thumbnail') +async def get_thumbnail(path: str, size: int = 400, q: int = 50): + """Serve WebP thumbnail with dynamic quality.""" + if not os.path.exists(path): + return Response(status_code=404) + img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, q, size) + return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500) + +@app.get('/full_res') +async def get_full_res(path: str): + """Serve full resolution image.""" + if not os.path.exists(path): + return Response(status_code=404) + img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None) + return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500) + +# ========================================== +# CORE LOGIC +# ========================================== + +def load_images(): + """Load images from source directory.""" + if not os.path.exists(state.source_dir): + ui.notify(f"Source not found: {state.source_dir}", type='warning') + return + + state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) + + # Reset page if out of bounds + if state.page >= state.total_pages: + state.page = 0 + + refresh_staged_info() + refresh_ui() + +def refresh_staged_info(): + """Update staged data and index maps.""" + state.staged_data = SorterEngine.get_staged_data() + + # Update green dots (pages with staged images) + state.green_dots.clear() + staged_keys = set(state.staged_data.keys()) + for idx, img_path in enumerate(state.all_images): + if img_path in staged_keys: + state.green_dots.add(idx // state.page_size) + + # Build index map for active category + state.index_map.clear() + + # Add staged images + for orig_path, info in state.staged_data.items(): + if info['cat'] == state.active_cat: + idx = _extract_index(info['name']) + if idx is not None: + state.index_map[idx] = orig_path + + # Add committed images from disk + cat_path = os.path.join(state.output_dir, state.active_cat) + if os.path.exists(cat_path): + for filename in os.listdir(cat_path): + if filename.startswith(state.active_cat): + idx = _extract_index(filename) + if idx is not None and idx not in state.index_map: + state.index_map[idx] = os.path.join(cat_path, filename) + +def _extract_index(filename: str) -> Optional[int]: + """Extract numeric index from filename (e.g., 'Cat_042.jpg' -> 42).""" + try: + return int(filename.rsplit('_', 1)[1].split('.')[0]) + except (ValueError, IndexError): + return None + +# ========================================== +# ACTIONS +# ========================================== + +def action_tag(img_path: str, manual_idx: Optional[int] = None): + """Tag an image with category and index.""" + idx = manual_idx if manual_idx is not None else state.next_index + ext = os.path.splitext(img_path)[1] + name = f"{state.active_cat}_{idx:03d}{ext}" + + # Check for conflicts + final_path = os.path.join(state.output_dir, state.active_cat, name) + staged_names = {v['name'] for v in state.staged_data.values() if v['cat'] == state.active_cat} + + if name in staged_names or os.path.exists(final_path): + ui.notify(f"Conflict: {name} exists. Using suffix.", type='warning') + name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}" + + SorterEngine.stage_image(img_path, state.active_cat, name) + + # Only auto-increment if we used the default next_index (not manual) + if manual_idx is None: + state.next_index = idx + 1 + + refresh_staged_info() + refresh_ui() + +def action_untag(img_path: str): + """Remove staging from an image.""" + SorterEngine.clear_staged_item(img_path) + refresh_staged_info() + refresh_ui() + +def action_delete(img_path: str): + """Delete image to trash.""" + SorterEngine.delete_to_trash(img_path) + load_images() + +def action_apply_page(): + """Apply staged changes for current page only.""" + batch = state.get_current_batch() + if not batch: + ui.notify("No images on current page", type='warning') + return + + SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode) + ui.notify(f"Page processed ({state.batch_mode})", type='positive') + load_images() + +async def action_apply_global(): + """Apply all staged changes globally.""" + ui.notify("Starting global apply... This may take a while.", type='info') + await run.io_bound( + SorterEngine.commit_global, + state.output_dir, + state.cleanup_mode, + state.batch_mode, + state.source_dir + ) + load_images() + ui.notify("Global apply complete!", type='positive') + +# ========================================== +# UI COMPONENTS +# ========================================== + +def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool = False, show_jump: bool = False): + """Open full-resolution image dialog with optional actions.""" + with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black'): + with ui.row().classes('w-full justify-between items-center p-2 bg-gray-900 text-white'): + ui.label(title or os.path.basename(path)).classes('font-bold truncate px-2') + + with ui.row().classes('gap-2'): + # Jump to page button + if show_jump and path in state.all_images: + def jump_to_image(): + img_idx = state.all_images.index(path) + target_page = img_idx // state.page_size + dialog.close() + set_page(target_page) + ui.notify(f"Jumped to page {target_page + 1}", type='info') + + ui.button(icon='location_searching', on_click=jump_to_image) \ + .props('flat round dense color=blue') \ + .tooltip('Jump to image location') + + # Untag button + if show_untag: + def untag_and_close(): + action_untag(path) + dialog.close() + ui.notify("Tag removed", type='positive') + + ui.button(icon='label_off', on_click=untag_and_close) \ + .props('flat round dense color=red') \ + .tooltip('Remove tag') + + ui.button(icon='close', on_click=dialog.close).props('flat round dense color=white') + + ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain max-h-[85vh]') + dialog.open() + +def render_sidebar(): + """Render category management sidebar.""" + state.sidebar_container.clear() + + with state.sidebar_container: + ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') + + # Number grid (1-25) + with ui.grid(columns=5).classes('gap-1 mb-4 w-full'): + for i in range(1, 26): + is_used = i in state.index_map + color = 'green' if is_used else 'grey-9' + + def make_click_handler(num: int): + def handler(): + if num in state.index_map: + # Number is used - open preview + img_path = state.index_map[num] + is_staged = img_path in state.staged_data + open_zoom_dialog( + img_path, + f"{state.active_cat} #{num}", + show_untag=is_staged, + show_jump=True + ) + else: + # Number is free - set as next index + state.next_index = num + render_sidebar() + return handler + + ui.button(str(i), on_click=make_click_handler(i)) \ + .props(f'color={color} size=sm flat') \ + .classes('w-full border border-gray-800') + + # Category selector + categories = state.get_categories() + + def on_category_change(e): + state.active_cat = e.value + refresh_staged_info() + render_sidebar() + + ui.select( + categories, + value=state.active_cat, + label="Active Category", + on_change=on_category_change + ).classes('w-full').props('dark outlined') + + # Add new category + with ui.row().classes('w-full items-center no-wrap mt-2'): + new_cat_input = ui.input(placeholder='New category...') \ + .props('dense outlined dark').classes('flex-grow') + + def add_category(): + if new_cat_input.value: + SorterEngine.add_category(new_cat_input.value) + state.active_cat = new_cat_input.value + refresh_staged_info() + render_sidebar() + + ui.button(icon='add', on_click=add_category).props('flat color=green') + + # Delete category + with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'): + def delete_category(): + SorterEngine.delete_category(state.active_cat) + refresh_staged_info() + render_sidebar() + + ui.button('DELETE CATEGORY', color='red', on_click=delete_category).classes('w-full') + + ui.separator().classes('my-4 bg-gray-700') + + # Index counter + with ui.row().classes('w-full items-end no-wrap'): + ui.number(label="Next Index", min=1, precision=0) \ + .bind_value(state, 'next_index') \ + .classes('flex-grow').props('dark outlined') + + def reset_index(): + state.next_index = (max(state.index_map.keys()) + 1) if state.index_map else 1 + render_sidebar() + + ui.button('πŸ”„', on_click=reset_index).props('flat color=white') + +def render_gallery(): + """Render image gallery grid.""" + state.grid_container.clear() + batch = state.get_current_batch() + + with state.grid_container: + with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): + for img_path in batch: + render_image_card(img_path) + +def render_image_card(img_path: str): + """Render individual image card.""" + is_staged = img_path in state.staged_data + thumb_size = 800 + + with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): + # Header with filename and actions + with ui.row().classes('w-full justify-between no-wrap mb-1'): + ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-gray-400 truncate') + with ui.row().classes('gap-0'): + ui.button( + icon='zoom_in', + on_click=lambda p=img_path: open_zoom_dialog(p) + ).props('flat size=sm dense color=white') + ui.button( + icon='delete', + on_click=lambda p=img_path: action_delete(p) + ).props('flat size=sm dense color=red') + + # Thumbnail + ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \ + .classes('w-full h-64 bg-black rounded') \ + .props('fit=contain no-spinner') + + # Tagging UI + if is_staged: + info = state.staged_data[img_path] + idx = _extract_index(info['name']) + idx_str = str(idx) if idx else "?" + ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full') + ui.button( + f"Untag (#{idx_str})", + on_click=lambda p=img_path: action_untag(p) + ).props('flat color=grey-5 dense').classes('w-full') + else: + with ui.row().classes('w-full no-wrap mt-2 gap-1'): + local_idx = ui.number(value=state.next_index, precision=0) \ + .props('dense dark outlined').classes('w-1/3') + ui.button( + 'Tag', + on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value)) + ).classes('w-2/3').props('color=green dense') + +def render_pagination(): + """Render pagination controls.""" + state.pagination_container.clear() + + if state.total_pages <= 1: + return + + with state.pagination_container: + # Page slider + ui.slider( + min=0, + max=state.total_pages - 1, + value=state.page, + on_change=lambda e: set_page(int(e.value)) + ).classes('w-1/2 mb-2').props('color=green') + + # Page buttons + with ui.row().classes('items-center gap-2'): + # Previous button + if state.page > 0: + ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat color=white') + + # Page numbers (show current Β±2) + start = max(0, state.page - 2) + end = min(state.total_pages, state.page + 3) + + for p in range(start, end): + dot = " 🟒" if p in state.green_dots else "" + color = "white" if p == state.page else "grey-6" + ui.button( + f"{p+1}{dot}", + on_click=lambda page=p: set_page(page) + ).props(f'flat color={color}') + + # Next button + if state.page < state.total_pages - 1: + ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat color=white') + +def set_page(p: int): + """Navigate to specific page.""" + state.page = max(0, min(p, state.total_pages - 1)) + refresh_ui() + +def refresh_ui(): + """Refresh all UI components.""" + render_sidebar() + render_pagination() + render_gallery() + +def handle_keyboard(e): + """Handle keyboard navigation.""" + if not e.action.keydown: + return + + if e.key.arrow_left and state.page > 0: + set_page(state.page - 1) + elif e.key.arrow_right and state.page < state.total_pages - 1: + set_page(state.page + 1) + +# ========================================== +# MAIN LAYOUT +# ========================================== + +def build_header(): + """Build application header.""" + with ui.header().classes('items-center bg-slate-900 text-white border-b border-gray-700').style('height: 70px'): + with ui.row().classes('w-full items-center gap-4 no-wrap px-4'): + ui.label('πŸ–ΌοΈ NiceSorter').classes('text-xl font-bold shrink-0 text-green-400') + + # Profile selector + profile_names = list(state.profiles.keys()) + + def change_profile(e): + state.profile_name = e.value + state.load_active_profile() + load_images() + + ui.select(profile_names, value=state.profile_name, on_change=change_profile) \ + .props('dark dense options-dense borderless').classes('w-32') + + # Source and output paths + with ui.row().classes('flex-grow gap-2'): + ui.input('Source').bind_value(state, 'source_dir') \ + .classes('flex-grow').props('dark dense outlined') + ui.input('Output').bind_value(state, 'output_dir') \ + .classes('flex-grow').props('dark dense outlined') + + ui.button(icon='save', on_click=state.save_current_profile) \ + .props('flat round color=white') + ui.button('LOAD', on_click=load_images) \ + .props('color=green flat').classes('font-bold border border-green-700') + + # View settings menu + with ui.button(icon='tune', color='white').props('flat round'): + with ui.menu().classes('bg-gray-800 text-white p-4'): + ui.label('VIEW SETTINGS').classes('text-xs font-bold mb-2') + + ui.label('Grid Columns:') + ui.slider( + min=2, max=8, step=1, + value=state.grid_cols, + on_change=lambda e: (setattr(state, 'grid_cols', e.value), refresh_ui()) + ).props('color=green') + + ui.label('Preview Quality:') + ui.slider( + min=10, max=100, step=10, + value=state.preview_quality, + on_change=lambda e: (setattr(state, 'preview_quality', e.value), refresh_ui()) + ).props('color=green label-always') + + ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)) \ + .props('color=green') + +def build_sidebar(): + """Build left sidebar.""" + with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'): + state.sidebar_container = ui.column().classes('w-full') + +def build_main_content(): + """Build main content area.""" + with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'): + state.pagination_container = ui.column().classes('w-full items-center mb-4') + state.grid_container = ui.column().classes('w-full') + + # Footer with batch controls + ui.separator().classes('my-10 bg-gray-800') + + with ui.row().classes('w-full justify-around p-6 bg-gray-950 rounded-xl border border-gray-800'): + # Tagged files mode + with ui.column(): + ui.label('TAGGED FILES:').classes('text-gray-500 text-xs font-bold') + ui.radio(['Copy', 'Move'], value=state.batch_mode) \ + .bind_value(state, 'batch_mode') \ + .props('inline dark color=green') + + # Untagged files mode + with ui.column(): + ui.label('UNTAGGED FILES:').classes('text-gray-500 text-xs font-bold') + ui.radio(['Keep', 'Move to Unused', 'Delete'], value=state.cleanup_mode) \ + .bind_value(state, 'cleanup_mode') \ + .props('inline dark color=green') + + # Action buttons + with ui.row().classes('items-center gap-6'): + ui.button('APPLY PAGE', on_click=action_apply_page) \ + .props('outline color=white lg') + + with ui.column().classes('items-center'): + ui.button('APPLY GLOBAL', on_click=action_apply_global) \ + .props('lg color=red-900') + ui.label('(Process All)').classes('text-xs text-gray-500') + +# ========================================== +# INITIALIZATION +# ========================================== + +build_header() +build_sidebar() +build_main_content() + +ui.keyboard(on_key=handle_keyboard) +ui.dark_mode().enable() +load_images() + +ui.run(title="NiceSorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 70dbe1a..5682ef1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ streamlit -Pillow \ No newline at end of file +Pillow +nicegui \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..ee115af --- /dev/null +++ b/start.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# 1. Navigate to app directory +cd /app + +# 2. Install dependencies (Including NiceGUI if missing) +# This checks your requirements.txt AND ensures nicegui is present +pip install --no-cache-dir -r requirements.txt + +# 3. Start NiceGUI in the Background (&) +# This runs silently while the script continues +echo "πŸš€ Starting NiceGUI on Port 8080..." +python3 gallery_app.py & + +# 4. Start Streamlit in the Foreground +# This keeps the container running +echo "πŸš€ Starting Streamlit on Port 8501..." +streamlit run app.py --server.port=8501 --server.address=0.0.0.0 \ No newline at end of file diff --git a/tab_gallery_sorter.py b/tab_gallery_sorter.py index 628a3fe..4df4525 100644 --- a/tab_gallery_sorter.py +++ b/tab_gallery_sorter.py @@ -2,35 +2,147 @@ import streamlit as st import os import math import concurrent.futures +from typing import Dict, Set, List, Optional, Tuple from engine import SorterEngine # ========================================== -# 1. CALLBACKS & STATE MANAGEMENT +# STATE MANAGEMENT # ========================================== -def trigger_refresh(): - """Forces the file cache to invalidate.""" - if 't5_file_id' not in st.session_state: st.session_state.t5_file_id = 0 - st.session_state.t5_file_id += 1 +class StreamlitState: + """Centralized state management with type hints.""" + + @staticmethod + def init(): + """Initialize all session state variables.""" + defaults = { + 't5_file_id': 0, + 't5_page': 0, + 't5_active_cat': 'Default', + 't5_next_index': 1, + 't5_op_mode': 'Copy', + 't5_cleanup_mode': 'Keep', + 't5_page_size': 24, + 't5_grid_cols': 4, + 't5_quality': 50, + } + for key, value in defaults.items(): + if key not in st.session_state: + st.session_state[key] = value + + @staticmethod + def trigger_refresh(): + """Force file cache invalidation.""" + st.session_state.t5_file_id += 1 + + @staticmethod + def change_page(delta: int): + """Navigate pages by delta.""" + st.session_state.t5_page = max(0, st.session_state.t5_page + delta) + + @staticmethod + def set_page(page_idx: int): + """Jump to specific page.""" + st.session_state.t5_page = page_idx + + @staticmethod + def slider_change(key: str): + """Handle slider-based page navigation (1-based to 0-based).""" + st.session_state.t5_page = st.session_state[key] - 1 -def cb_tag_image(img_path, selected_cat, index_val, path_o): - """Tags image with manual index and collision handling.""" - if selected_cat.startswith("---") or selected_cat == "": +# ========================================== +# CACHING & DATA LOADING +# ========================================== + +@st.cache_data(show_spinner=False) +def get_cached_images(path: str, mutation_id: int) -> List[str]: + """Scan folder for images. mutation_id forces refresh.""" + return SorterEngine.get_images(path, recursive=True) + +@st.cache_data(show_spinner=False, max_entries=2000) +def get_cached_thumbnail(path: str, quality: int, target_size: int, mtime: float) -> Optional[bytes]: + """Load and compress thumbnail with caching.""" + try: + return SorterEngine.compress_for_web(path, quality, target_size) + except Exception: + return None + +@st.cache_data(show_spinner=False) +def get_cached_green_dots(all_images: List[str], page_size: int, staged_keys: frozenset) -> Set[int]: + """ + Calculate which pages have tagged images (cached). + Returns set of page indices with staged images. + """ + staged_set = set(staged_keys) + tagged_pages = set() + + for idx, img_path in enumerate(all_images): + if img_path in staged_set: + tagged_pages.add(idx // page_size) + + return tagged_pages + +@st.cache_data(show_spinner=False) +def build_index_map(active_cat: str, path_o: str, staged_data_frozen: frozenset) -> Dict[int, str]: + """ + Build mapping of index numbers to file paths for active category. + Returns: {1: '/path/to/Cat_001.jpg', 2: '/path/to/Cat_002.jpg', ...} + """ + index_map = {} + + # Convert frozenset back to dict for processing + staged_dict = {k: v for k, v in staged_data_frozen} + + # Check staging area + for orig_path, info in staged_dict.items(): + if info['cat'] == active_cat: + idx = _extract_index(info['name']) + if idx is not None: + index_map[idx] = orig_path + + # Check disk + cat_path = os.path.join(path_o, active_cat) + if os.path.exists(cat_path): + for filename in os.listdir(cat_path): + if filename.startswith(active_cat): + idx = _extract_index(filename) + if idx is not None and idx not in index_map: + index_map[idx] = os.path.join(cat_path, filename) + + return index_map + +def _extract_index(filename: str) -> Optional[int]: + """Extract numeric index from filename (e.g., 'Cat_042.jpg' -> 42).""" + try: + parts = filename.rsplit('_', 1) + if len(parts) > 1: + num_str = parts[1].split('.')[0] + return int(num_str) + except (ValueError, IndexError): + pass + return None + +# ========================================== +# ACTIONS +# ========================================== + +def action_tag(img_path: str, selected_cat: str, index_val: int, path_o: str): + """Tag image with category and index, handling collisions.""" + if selected_cat.startswith("---") or not selected_cat: st.toast("⚠️ Select a valid category first!", icon="🚫") return - + ext = os.path.splitext(img_path)[1] base_name = f"{selected_cat}_{index_val:03d}" new_name = f"{base_name}{ext}" - # Collision Detection + # Collision detection staged = SorterEngine.get_staged_data() staged_names = {v['name'] for v in staged.values() if v['cat'] == selected_cat} - dest_path = os.path.join(path_o, selected_cat, new_name) + collision = False suffix = 1 - while new_name in staged_names or os.path.exists(dest_path): collision = True new_name = f"{base_name}_{suffix}{ext}" @@ -41,460 +153,591 @@ def cb_tag_image(img_path, selected_cat, index_val, path_o): if collision: st.toast(f"⚠️ Conflict! Saved as: {new_name}", icon="πŸ”€") + + # Auto-increment index + st.session_state.t5_next_index = index_val + 1 -def cb_untag_image(img_path): +def action_untag(img_path: str): + """Remove staging from image.""" SorterEngine.clear_staged_item(img_path) -def cb_delete_image(img_path): +def action_delete(img_path: str): + """Delete image to trash.""" SorterEngine.delete_to_trash(img_path) - trigger_refresh() + StreamlitState.trigger_refresh() -def cb_apply_batch(current_batch, path_o, cleanup_mode, operation): +def action_apply_batch(current_batch: List[str], path_o: str, cleanup_mode: str, operation: str): + """Apply staged changes for current page.""" SorterEngine.commit_batch(current_batch, path_o, cleanup_mode, operation) - trigger_refresh() + StreamlitState.trigger_refresh() -def cb_apply_global(path_o, cleanup_mode, operation, path_s): +def action_apply_global(path_o: str, cleanup_mode: str, operation: str, path_s: str): + """Apply all staged changes globally.""" SorterEngine.commit_global(path_o, cleanup_mode, operation, source_root=path_s) - trigger_refresh() + StreamlitState.trigger_refresh() -def cb_change_page(delta): - if 't5_page' not in st.session_state: st.session_state.t5_page = 0 - st.session_state.t5_page += delta +def action_add_category(name: str): + """Add new category.""" + if name: + SorterEngine.add_category(name) + st.session_state.t5_active_cat = name -def cb_set_page(page_idx): - st.session_state.t5_page = page_idx - -def cb_slider_change(key): - val = st.session_state[key] - st.session_state.t5_page = val - 1 +def action_rename_category(old_name: str, new_name: str): + """Rename category.""" + if new_name and new_name != old_name: + SorterEngine.rename_category(old_name, new_name) + st.session_state.t5_active_cat = new_name +def action_delete_category(cat_name: str): + """Delete category.""" + SorterEngine.delete_category(cat_name) + # Reset to first available category + cats = SorterEngine.get_categories() or ["Default"] + st.session_state.t5_active_cat = cats[0] # ========================================== -# 2. CACHING & DATA LOADING +# DIALOGS # ========================================== -@st.cache_data(show_spinner=False) -def get_cached_images(path, mutation_id): - """Scans folder. mutation_id forces refresh.""" - return SorterEngine.get_images(path, recursive=True) - -@st.cache_data(show_spinner=False, max_entries=2000) -def get_cached_thumbnail(path, quality, target_size, mtime): - """Loads and compresses thumbnail.""" - return SorterEngine.compress_for_web(path, quality, target_size) - -@st.dialog("πŸ” High-Res Inspection", width="large") -def view_high_res(img_path): +@st.dialog("πŸ” Full Resolution", width="large") +def view_high_res(img_path: str): """Modal for full resolution inspection.""" img_data = SorterEngine.compress_for_web(img_path, quality=90, target_size=None) if img_data: st.image(img_data, use_container_width=True) - st.caption(f"Filename: {os.path.basename(img_path)}") + st.caption(f"πŸ“ {img_path}") + else: + st.error(f"Could not load: {img_path}") @st.dialog("πŸ–ΌοΈ Tag Preview", width="large") -def view_tag_preview(img_path, title): - """Shows the image associated with a number in the grid.""" +def view_tag_preview(img_path: str, title: str): + """Show image associated with a numbered tag.""" st.subheader(title) - # Load image (Fast WebP) - # We use target_size=800 for a good quality preview img_data = SorterEngine.compress_for_web(img_path, quality=80, target_size=800) - if img_data: st.image(img_data, use_container_width=True) - st.caption(f"Source: {img_path}") + st.caption(f"πŸ“ {img_path}") else: - st.error(f"Could not load image: {img_path}") + st.error(f"Could not load: {img_path}") -@st.cache_data(show_spinner=False) -def get_cached_green_dots(all_images, page_size, staged_keys): - """ - Calculates which pages have tags. - Cached based on the specific keys in the staging area. - """ - # We reconstruct the set of staged keys from the frozenset - staged_set = set(staged_keys) - tagged_pages = set() - - # Efficient O(N) scan ONLY when tagging changes - for idx, img_path in enumerate(all_images): - if img_path in staged_set: - tagged_pages.add(idx // page_size) - - return tagged_pages # ========================================== -# 3. FRAGMENTS +# UI COMPONENTS # ========================================== @st.fragment -def render_sidebar_content(path_o): +def render_sidebar_content(path_o: str): + """Render category management sidebar.""" st.divider() st.subheader("🏷️ Category Manager") - # --- 1. PREPARE CATEGORIES --- - cats = SorterEngine.get_categories() - processed_cats = [] - last_char = "" - if cats: - for cat in cats: - current_char = cat[0].upper() - if last_char and current_char != last_char: - processed_cats.append(f"--- {current_char} ---") - processed_cats.append(cat) - last_char = current_char - - # --- 2. INSTANT STATE SYNC (The Fix) --- - # We check the radio widget's state ('t5_radio_select') BEFORE rendering the grid. - # This ensures the grid sees the new selection immediately. + # Get and process categories with separators + cats = SorterEngine.get_categories() or ["Default"] + processed_cats = _add_category_separators(cats) + + # Sync radio selection immediately if "t5_radio_select" in st.session_state: new_selection = st.session_state.t5_radio_select - # Only update if it's a valid category (ignore separators) if not new_selection.startswith("---"): st.session_state.t5_active_cat = new_selection - - # Ensure default exists - if "t5_active_cat" not in st.session_state: - st.session_state.t5_active_cat = cats[0] if cats else "Default" + + if "t5_active_cat" not in st.session_state: + st.session_state.t5_active_cat = cats[0] current_cat = st.session_state.t5_active_cat - # --- 3. VISUAL NUMBER GRID (1-25) --- + # NUMBER GRID (1-25) with previews if current_cat and not current_cat.startswith("---"): - st.caption(f"Map: **{current_cat}**") + st.caption(f"**{current_cat}** Index Map") - # A. Build Index Map: { number: image_path } - index_map = {} - - # Check Staging + # Build index map (cached) staged = SorterEngine.get_staged_data() - for orig_path, info in staged.items(): - if info['cat'] == current_cat: - try: - parts = info['name'].rsplit('_', 1) - num_part = parts[1].split('.')[0] - index_map[int(num_part)] = orig_path - except: pass - - # Check Disk - cat_path = os.path.join(path_o, current_cat) - if os.path.exists(cat_path): - for f in os.listdir(cat_path): - if f.startswith(current_cat) and "_" in f: - try: - parts = f.rsplit('_', 1) - num_part = parts[1].split('.')[0] - idx = int(num_part) - if idx not in index_map: - index_map[idx] = os.path.join(cat_path, f) - except: pass + staged_frozen = frozenset(staged.items()) + index_map = build_index_map(current_cat, path_o, staged_frozen) - # B. Render Grid + # Render 5x5 grid grid_cols = st.columns(5, gap="small") for i in range(1, 26): is_used = i in index_map btn_type = "primary" if is_used else "secondary" - with grid_cols[(i-1) % 5]: + with grid_cols[(i - 1) % 5]: if st.button(f"{i}", key=f"grid_{i}", type=btn_type, use_container_width=True): st.session_state.t5_next_index = i if is_used: - file_path = index_map[i] - view_tag_preview(file_path, f"{current_cat} #{i}") + view_tag_preview(index_map[i], f"{current_cat} #{i}") else: - st.toast(f"Next Index set to #{i}") + st.toast(f"Next index set to #{i}") + st.divider() - - # --- 4. RADIO SELECTION --- - # We render the radio here, but its value was already used above! - st.radio("Active Tag", processed_cats, key="t5_radio_select") - - # --- 5. MANUAL INPUT --- + + # CATEGORY SELECTOR + st.radio("Active Category", processed_cats, key="t5_radio_select") + + # INDEX CONTROLS st.caption("Tagging Settings") c_num1, c_num2 = st.columns([3, 1], vertical_alignment="bottom") - if "t5_next_index" not in st.session_state: st.session_state.t5_next_index = 1 - c_num1.number_input("Next Number #", min_value=1, step=1, key="t5_next_index") + c_num1.number_input("Next Index #", min_value=1, step=1, key="t5_next_index") - if c_num2.button("πŸ”„", help="Auto-detect next number"): - used_indices = index_map.keys() - next_val = max(used_indices) + 1 if used_indices else 1 - st.session_state.t5_next_index = next_val + if c_num2.button("πŸ”„", help="Auto-detect next index"): + used_indices = list(index_map.keys()) if index_map else [] + st.session_state.t5_next_index = max(used_indices) + 1 if used_indices else 1 st.rerun() - + st.divider() - # ... (Add/Edit Tabs - Keep existing code) ... + # CATEGORY MANAGEMENT TABS tab_add, tab_edit = st.tabs(["βž• Add", "✏️ Edit"]) + with tab_add: c1, c2 = st.columns([3, 1]) - new_cat = c1.text_input("New Name", label_visibility="collapsed", placeholder="New...", key="t5_new_cat") + new_cat = c1.text_input( + "New Category", + label_visibility="collapsed", + placeholder="Enter name...", + key="t5_new_cat" + ) if c2.button("Add", key="btn_add_cat"): - if new_cat: - SorterEngine.add_category(new_cat) - st.rerun() + action_add_category(new_cat) + st.rerun() + with tab_edit: - target_cat = st.session_state.t5_active_cat - if target_cat and not target_cat.startswith("---") and target_cat in cats: - st.caption(f"Editing: **{target_cat}**") - rename_val = st.text_input("Rename to:", value=target_cat, key=f"ren_{target_cat}") - if st.button("πŸ’Ύ Save", key=f"save_{target_cat}", use_container_width=True): - if rename_val and rename_val != target_cat: - SorterEngine.rename_category(target_cat, rename_val) - st.session_state.t5_active_cat = rename_val - st.rerun() + if current_cat and not current_cat.startswith("---") and current_cat in cats: + st.caption(f"Editing: **{current_cat}**") + + rename_val = st.text_input( + "Rename to:", + value=current_cat, + key=f"ren_{current_cat}" + ) + + if st.button("πŸ’Ύ Save", key=f"save_{current_cat}", use_container_width=True): + action_rename_category(current_cat, rename_val) + st.rerun() + st.markdown("---") - if st.button("πŸ—‘οΈ Delete", key=f"del_cat_{target_cat}", type="primary", use_container_width=True): - SorterEngine.delete_category(target_cat) + + if st.button( + "πŸ—‘οΈ Delete Category", + key=f"del_cat_{current_cat}", + type="primary", + use_container_width=True + ): + action_delete_category(current_cat) st.rerun() -# NOTE: Do NOT use @st.fragment here. -# Navigation controls must trigger a full app rerun to load the new batch of images. -# CHANGED: Added 'tagged_pages_set' to arguments -def render_pagination_carousel(key_suffix, total_pages, current_page, tagged_pages_set): - """ - Renders pagination. No calculation hereβ€”just pure UI rendering. - """ - if total_pages <= 1: return +def _add_category_separators(cats: List[str]) -> List[str]: + """Add alphabetical separators between categories.""" + processed = [] + last_char = "" + + for cat in cats: + current_char = cat[0].upper() + if last_char and current_char != last_char: + processed.append(f"--- {current_char} ---") + processed.append(cat) + last_char = current_char + + return processed - # 1. Rapid Seeker Slider (1-BASED) +def render_pagination_carousel(key_suffix: str, total_pages: int, current_page: int, tagged_pages: Set[int]): + """Render pagination controls with green dot indicators.""" + if total_pages <= 1: + return + + # Rapid navigation slider (1-based) st.slider( - "Rapid Navigation", - min_value=1, max_value=total_pages, value=current_page + 1, step=1, - key=f"slider_{key_suffix}", label_visibility="collapsed", - on_change=cb_slider_change, args=(f"slider_{key_suffix}",) + "Page Navigator", + min_value=1, + max_value=total_pages, + value=current_page + 1, + step=1, + key=f"slider_{key_suffix}", + label_visibility="collapsed", + on_change=StreamlitState.slider_change, + args=(f"slider_{key_suffix}",) ) - - # 2. Window Logic (Calculate range of buttons to show) - window_radius = 2 + + # Calculate button window (show current Β±2 pages) + window_radius = 2 start_p = max(0, current_page - window_radius) end_p = min(total_pages, current_page + window_radius + 1) - # Adjust window near edges to keep width constant - if current_page < window_radius: + # Adjust near edges to maintain consistent width + if current_page < window_radius: end_p = min(total_pages, 5) - elif current_page > total_pages - window_radius - 1: + elif current_page > total_pages - window_radius - 1: start_p = max(0, total_pages - 5) - - num_page_buttons = end_p - start_p - # Safety check if page count is small - if num_page_buttons < 1: + + num_buttons = end_p - start_p + if num_buttons < 1: start_p = 0 end_p = total_pages - num_page_buttons = total_pages - - # 3. Render Buttons - # We create columns: [Prev] + [1] [2] [3] ... + [Next] - cols = st.columns([1] + [1] * num_page_buttons + [1]) + num_buttons = total_pages - # --- PREV BUTTON --- + # Render button row: [Prev] [1] [2] [3] ... [Next] + cols = st.columns([1] + [1] * num_buttons + [1]) + + # Previous button with cols[0]: - st.button("β—€", disabled=(current_page == 0), - on_click=cb_change_page, args=(-1,), - key=f"prev_{key_suffix}", use_container_width=True) + st.button( + "β—€", + disabled=(current_page == 0), + on_click=StreamlitState.change_page, + args=(-1,), + key=f"prev_{key_suffix}", + use_container_width=True + ) - # --- NUMBERED BUTTONS --- + # Page number buttons for i, p_idx in enumerate(range(start_p, end_p)): with cols[i + 1]: label = str(p_idx + 1) - # Add Green Dot if this page has tagged items - if p_idx in tagged_pages_set: + if p_idx in tagged_pages: label += " 🟒" - # Highlight Current Page btn_type = "primary" if p_idx == current_page else "secondary" - st.button(label, type=btn_type, - key=f"btn_p{p_idx}_{key_suffix}", - use_container_width=True, - on_click=cb_set_page, args=(p_idx,)) - - # --- NEXT BUTTON --- + st.button( + label, + type=btn_type, + key=f"btn_p{p_idx}_{key_suffix}", + use_container_width=True, + on_click=StreamlitState.set_page, + args=(p_idx,) + ) + + # Next button with cols[-1]: - st.button("β–Ά", disabled=(current_page >= total_pages - 1), - on_click=cb_change_page, args=(1,), - key=f"next_{key_suffix}", use_container_width=True) + st.button( + "β–Ά", + disabled=(current_page >= total_pages - 1), + on_click=StreamlitState.change_page, + args=(1,), + key=f"next_{key_suffix}", + use_container_width=True + ) @st.fragment -def render_gallery_grid(current_batch, quality, grid_cols, path_o): - """Grid with Zoom, Parallel Load, and Manual Indexing.""" +def render_gallery_grid( + current_batch: List[str], + quality: int, + grid_cols: int, + path_o: str +): + """Render image gallery grid with parallel loading.""" staged = SorterEngine.get_staged_data() history = SorterEngine.get_processed_log() - selected_cat = st.session_state.get("t5_active_cat", "Default") + selected_cat = st.session_state.t5_active_cat tagging_disabled = selected_cat.startswith("---") - if "t5_next_index" not in st.session_state: st.session_state.t5_next_index = 1 target_size = int(2400 / grid_cols) - - # Parallel Load - batch_cache = {} - def fetch_one(p): - try: - mtime = os.path.getmtime(p) - return p, get_cached_thumbnail(p, quality, target_size, mtime) - except: return p, None - - with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor: - future_to_path = {executor.submit(fetch_one, p): p for p in current_batch} - for future in concurrent.futures.as_completed(future_to_path): - p, data = future.result() - batch_cache[p] = data - + + # Parallel thumbnail loading + batch_cache = _load_thumbnails_parallel(current_batch, quality, target_size) + + # Render grid cols = st.columns(grid_cols) + for idx, img_path in enumerate(current_batch): - unique_key = f"frag_{os.path.basename(img_path)}" with cols[idx % grid_cols]: - is_staged = img_path in staged - is_processed = img_path in history + _render_image_card( + img_path=img_path, + batch_cache=batch_cache, + staged=staged, + history=history, + selected_cat=selected_cat, + tagging_disabled=tagging_disabled, + path_o=path_o + ) + +def _load_thumbnails_parallel( + batch: List[str], + quality: int, + target_size: int +) -> Dict[str, Optional[bytes]]: + """Load thumbnails in parallel using ThreadPoolExecutor.""" + batch_cache = {} + + def fetch_one(path: str) -> Tuple[str, Optional[bytes]]: + try: + mtime = os.path.getmtime(path) + data = get_cached_thumbnail(path, quality, target_size, mtime) + return path, data + except Exception: + return path, None + + with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor: + futures = {executor.submit(fetch_one, p): p for p in batch} + for future in concurrent.futures.as_completed(futures): + path, data = future.result() + batch_cache[path] = data + + return batch_cache + +def _render_image_card( + img_path: str, + batch_cache: Dict[str, Optional[bytes]], + staged: Dict, + history: Dict, + selected_cat: str, + tagging_disabled: bool, + path_o: str +): + """Render individual image card.""" + unique_key = f"frag_{os.path.basename(img_path)}" + is_staged = img_path in staged + is_processed = img_path in history + + with st.container(border=True): + # Header: filename + zoom + delete + c_name, c_zoom, c_del = st.columns([4, 1, 1]) + c_name.caption(os.path.basename(img_path)[:15]) + + if c_zoom.button("πŸ”", key=f"zoom_{unique_key}"): + view_high_res(img_path) + + c_del.button( + "❌", + key=f"del_{unique_key}", + on_click=action_delete, + args=(img_path,) + ) + + # Status indicator + if is_staged: + staged_info = staged[img_path] + idx = _extract_index(staged_info['name']) + idx_str = f" #{idx}" if idx else "" + st.success(f"🏷️ {staged_info['cat']}{idx_str}") + elif is_processed: + st.info(f"βœ… {history[img_path]['action']}") + + # Thumbnail + img_data = batch_cache.get(img_path) + if img_data: + st.image(img_data, use_container_width=True) + else: + st.error("Failed to load") + + # Action buttons + if not is_staged: + c_idx, c_tag = st.columns([1, 2], vertical_alignment="bottom") - with st.container(border=True): - # Header - c_name, c_zoom, c_del = st.columns([4, 1, 1]) - c_name.caption(os.path.basename(img_path)[:10]) - if c_zoom.button("πŸ”", key=f"zoom_{unique_key}"): view_high_res(img_path) - c_del.button("❌", key=f"del_{unique_key}", on_click=cb_delete_image, args=(img_path,)) - - # Status - if is_staged: st.success(f"🏷️ {staged[img_path]['cat']}") - elif is_processed: st.info(f"βœ… {history[img_path]['action']}") - - # Image - img_data = batch_cache.get(img_path) - if img_data: st.image(img_data, use_container_width=True) - - # Actions - if not is_staged: - c_idx, c_tag = st.columns([1, 2], vertical_alignment="bottom") - card_index = c_idx.number_input("Idx", min_value=1, step=1, - value=st.session_state.t5_next_index, label_visibility="collapsed", key=f"idx_{unique_key}") - - c_tag.button("Tag", key=f"tag_{unique_key}", disabled=tagging_disabled, - use_container_width=True, on_click=cb_tag_image, - args=(img_path, selected_cat, card_index, path_o)) - else: - # CASE: Image is STAGED - # We want to show "Untag (#5)" - - # 1. Get the current filename from staging data - staged_name = staged[img_path]['name'] # e.g., "Category_005.jpg" - - # 2. Extract the number - untag_label = "Untag" - try: - # Split by underscore, grab the last part, remove extension - parts = staged_name.rsplit('_', 1) - if len(parts) > 1: - num_str = parts[1].split('.')[0] # "005" - untag_label = f"Untag (#{int(num_str)})" - except: - pass - - st.button(untag_label, key=f"untag_{unique_key}", use_container_width=True, - on_click=cb_untag_image, args=(img_path,)) - + card_index = c_idx.number_input( + "Index", + min_value=1, + step=1, + value=st.session_state.t5_next_index, + label_visibility="collapsed", + key=f"idx_{unique_key}" + ) + + c_tag.button( + "Tag", + key=f"tag_{unique_key}", + disabled=tagging_disabled, + use_container_width=True, + on_click=action_tag, + args=(img_path, selected_cat, card_index, path_o) + ) + else: + # Show untag with index number + staged_name = staged[img_path]['name'] + idx = _extract_index(staged_name) + untag_label = f"Untag (#{idx})" if idx else "Untag" + + st.button( + untag_label, + key=f"untag_{unique_key}", + use_container_width=True, + on_click=action_untag, + args=(img_path,) + ) @st.fragment -def render_batch_actions(current_batch, path_o, page_num, path_s): - st.write(f"### πŸš€ Processing Actions") - st.caption("Settings apply to both Page and Global actions.") +def render_batch_actions( + current_batch: List[str], + path_o: str, + page_num: int, + path_s: str +): + """Render batch processing controls.""" + st.write("### πŸš€ Processing Actions") + st.caption("Settings apply to both Page and Global actions") + c_set1, c_set2 = st.columns(2) - # Default is Copy - op_mode = c_set1.radio("Tagged Files:", ["Copy", "Move"], horizontal=True, key="t5_op_mode") - cleanup = c_set2.radio("Untagged Files:", ["Keep", "Move to Unused", "Delete"], horizontal=True, key="t5_cleanup_mode") + + c_set1.radio( + "Tagged Files:", + ["Copy", "Move"], + horizontal=True, + key="t5_op_mode" + ) + + c_set2.radio( + "Untagged Files:", + ["Keep", "Move to Unused", "Delete"], + horizontal=True, + key="t5_cleanup_mode" + ) st.divider() + c_btn1, c_btn2 = st.columns(2) - if c_btn1.button(f"APPLY PAGE {page_num}", type="secondary", use_container_width=True, - on_click=cb_apply_batch, args=(current_batch, path_o, cleanup, op_mode)): - st.toast(f"Page {page_num} Applied!") + # Apply Page button + if c_btn1.button( + f"APPLY PAGE {page_num}", + type="secondary", + use_container_width=True, + on_click=action_apply_batch, + args=( + current_batch, + path_o, + st.session_state.t5_cleanup_mode, + st.session_state.t5_op_mode + ) + ): + st.toast(f"Page {page_num} applied!") + st.rerun() + + # Apply Global button + if c_btn2.button( + "APPLY ALL (GLOBAL)", + type="primary", + use_container_width=True, + help="Process ALL tagged files", + on_click=action_apply_global, + args=( + path_o, + st.session_state.t5_cleanup_mode, + st.session_state.t5_op_mode, + path_s + ) + ): + st.toast("Global apply complete!") st.rerun() - if c_btn2.button("APPLY ALL (GLOBAL)", type="primary", use_container_width=True, - help="Process ALL tagged files.", - on_click=cb_apply_global, args=(path_o, cleanup, op_mode, path_s)): - st.toast("Global Apply Complete!") - st.rerun() - - # ========================================== -# 4. MAIN RENDER +# MAIN RENDER FUNCTION # ========================================== -def render(quality, profile_name): +def render(quality: int, profile_name: str): + """Main render function for Streamlit app.""" st.subheader("πŸ–ΌοΈ Gallery Staging Sorter") - # --- 1. INITIALIZE STATE --- - if 't5_file_id' not in st.session_state: st.session_state.t5_file_id = 0 - if 't5_page' not in st.session_state: st.session_state.t5_page = 0 + # Initialize state + StreamlitState.init() - # --- 2. LOAD PROFILES & PATHS --- + # Load profiles and paths profiles = SorterEngine.load_profiles() p_data = profiles.get(profile_name, {}) - c1, c2 = st.columns(2) - path_s = c1.text_input("Source Folder", value=p_data.get("tab5_source", "/storage"), key="t5_s") - path_o = c2.text_input("Output Folder", value=p_data.get("tab5_out", "/storage"), key="t5_o") - # Save if changed - if path_s != p_data.get("tab5_source") or path_o != p_data.get("tab5_out"): - if st.button("πŸ’Ύ Save Settings"): - SorterEngine.save_tab_paths(profile_name, t5_s=path_s, t5_o=path_o) - trigger_refresh() - st.rerun() - - if not os.path.exists(path_s): - st.warning("⚠️ Source path does not exist.") + c1, c2, c3 = st.columns([3, 3, 1]) + + path_s = c1.text_input( + "Source Folder", + value=p_data.get("tab5_source", "/storage"), + key="t5_s" + ) + + path_o = c2.text_input( + "Output Folder", + value=p_data.get("tab5_out", "/storage"), + key="t5_o" + ) + + # Save settings button + if c3.button("πŸ’Ύ Save", use_container_width=True): + SorterEngine.save_tab_paths(profile_name, t5_s=path_s, t5_o=path_o) + StreamlitState.trigger_refresh() + st.toast("Settings saved!") + st.rerun() + + # Validate source path + if not os.path.exists(path_s): + st.warning("⚠️ Source path does not exist") return - - # --- 3. RENDER SIDEBAR --- + + # Render sidebar with st.sidebar: - # We pass path_o to show the Green Dots on the number grid render_sidebar_content(path_o) - - # --- 4. VIEW SETTINGS --- - with st.expander("πŸ‘€ View Settings"): - c_v1, c_v2 = st.columns(2) - page_size = c_v1.slider("Images per Page", 12, 100, 24, 4) - grid_cols = c_v2.slider("Grid Columns", 2, 8, 4) - - # --- 5. LOAD FILES (Cached) --- + + # View settings + with st.expander("πŸ‘€ View Settings", expanded=False): + c_v1, c_v2, c_v3 = st.columns(3) + + st.session_state.t5_page_size = c_v1.slider( + "Images/Page", + 12, 100, + st.session_state.t5_page_size, + 4 + ) + + st.session_state.t5_grid_cols = c_v2.slider( + "Grid Columns", + 2, 8, + st.session_state.t5_grid_cols + ) + + st.session_state.t5_quality = c_v3.slider( + "Preview Quality", + 10, 100, + st.session_state.t5_quality, + 10 + ) + + # Load images (cached) all_images = get_cached_images(path_s, st.session_state.t5_file_id) + if not all_images: - st.info("No images found.") + st.info("πŸ“‚ No images found in source folder") return - - # --- 6. PAGINATION MATH --- - total_items = len(all_images) - total_pages = math.ceil(total_items / page_size) - # Safety bounds - if st.session_state.t5_page >= total_pages: st.session_state.t5_page = max(0, total_pages - 1) - if st.session_state.t5_page < 0: st.session_state.t5_page = 0 + # Pagination calculations + page_size = st.session_state.t5_page_size + total_pages = math.ceil(len(all_images) / page_size) - start_idx = st.session_state.t5_page * page_size - end_idx = start_idx + page_size - current_batch = all_images[start_idx:end_idx] - - # --- 7. CALCULATE GREEN DOTS (Optimized/Cached) --- + # Bounds checking + if st.session_state.t5_page >= total_pages: + st.session_state.t5_page = max(0, total_pages - 1) + if st.session_state.t5_page < 0: + st.session_state.t5_page = 0 + + current_page = st.session_state.t5_page + start_idx = current_page * page_size + current_batch = all_images[start_idx : start_idx + page_size] + + # Calculate green dots (cached) staged = SorterEngine.get_staged_data() - # Frozenset is required for caching to work on a dictionary keyset - green_dots_set = get_cached_green_dots(all_images, page_size, frozenset(staged.keys())) - - # --- 8. RENDER UI COMPONENTS --- + green_dots = get_cached_green_dots( + all_images, + page_size, + frozenset(staged.keys()) + ) + + # Render UI components + st.divider() + + # Top pagination + render_pagination_carousel("top", total_pages, current_page, green_dots) + + # Gallery grid + render_gallery_grid( + current_batch, + st.session_state.t5_quality, + st.session_state.t5_grid_cols, + path_o + ) st.divider() - # TOP PAGINATION - render_pagination_carousel("top", total_pages, st.session_state.t5_page, green_dots_set) - - # GALLERY GRID - render_gallery_grid(current_batch, quality, grid_cols, path_o) + # Bottom pagination + render_pagination_carousel("bot", total_pages, current_page, green_dots) st.divider() - # BOTTOM PAGINATION - render_pagination_carousel("bot", total_pages, st.session_state.t5_page, green_dots_set) - - st.divider() - - # BATCH ACTIONS (Only called ONCE here) - render_batch_actions(current_batch, path_o, st.session_state.t5_page + 1, path_s) \ No newline at end of file + # Batch actions + render_batch_actions(current_batch, path_o, current_page + 1, path_s) \ No newline at end of file