From 39153d34938b47ceb117e47ca5b35609540390b3 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 19:24:25 +0100 Subject: [PATCH 01/22] Update requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 0d1eca4ef33aa87feb561f1bb9dd95e5c36b3f07 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 19:25:01 +0100 Subject: [PATCH 02/22] Add gallery_app.py --- gallery_app.py | 338 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 gallery_app.py diff --git a/gallery_app.py b/gallery_app.py new file mode 100644 index 0000000..94bd091 --- /dev/null +++ b/gallery_app.py @@ -0,0 +1,338 @@ +import os +import math +import asyncio +from nicegui import ui, app, run +from fastapi import Response +from engine import SorterEngine + +# ========================================== +# 1. STATE MANAGEMENT +# ========================================== +class AppState: + def __init__(self): + # Load Defaults + profiles = SorterEngine.load_profiles() + p_data = profiles.get("Default", {}) + + self.source_dir = p_data.get("tab5_source", ".") + self.output_dir = p_data.get("tab5_out", ".") + + self.page = 0 + self.page_size = 24 + self.grid_cols = 4 + self.active_cat = "Default" + self.next_index = 1 + + # Caches + self.all_images = [] + self.staged_data = {} + self.green_dots = set() + self.index_map = {} # For 5x5 sidebar grid + +state = AppState() + +# ========================================== +# 2. FAST THUMBNAIL SERVER (The Speed Secret) +# ========================================== +# Instead of embedding bytes in HTML (slow), we create a dedicated API +# that serves WebP thumbnails. The browser loads these in parallel. + +@app.get('/thumbnail') +async def get_thumbnail(path: str, size: int = 400): + """ + Serves a resized WebP thumbnail. + Uses run.cpu_bound to prevent blocking the UI. + """ + if not os.path.exists(path): + return Response(status_code=404) + + # We use a lower quality (q=70) for grid speed + img_bytes = await run.cpu_bound( + SorterEngine.compress_for_web, path, quality=70, target_size=size + ) + + if img_bytes: + return Response(content=img_bytes, media_type="image/webp") + return Response(status_code=500) + +@app.get('/full_res') +async def get_full_res(path: str): + """Serves high-quality image for Zoom/Preview.""" + img_bytes = await run.cpu_bound( + SorterEngine.compress_for_web, path, quality=90, target_size=None + ) + return Response(content=img_bytes, media_type="image/webp") + + +# ========================================== +# 3. LOGIC & ACTIONS +# ========================================== + +def load_images(): + """Scans folder and updates state.""" + if os.path.exists(state.source_dir): + state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) + refresh_staged_info() + else: + ui.notify(f"Source not found: {state.source_dir}", type='warning') + +def refresh_staged_info(): + """Refreshes DB data and Green Dots cache.""" + state.staged_data = SorterEngine.get_staged_data() + + # Calculate Green Dots (Pages with tags) + 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) + + # Calculate Sidebar Grid Map (Numbers used in current cat) + state.index_map.clear() + # 1. Staging + for info in state.staged_data.values(): + if info['cat'] == state.active_cat: + try: + num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) + state.index_map[num] = True + except: pass + # 2. Disk + cat_path = os.path.join(state.output_dir, state.active_cat) + if os.path.exists(cat_path): + for f in os.listdir(cat_path): + if f.startswith(state.active_cat) and "_" in f: + try: + num = int(f.rsplit('_', 1)[1].split('.')[0]) + state.index_map[num] = True + except: pass + +def get_current_batch(): + start = state.page * state.page_size + return state.all_images[start : start + state.page_size] + +async def action_tag(img_path, manual_idx=None): + """Tags an image.""" + idx = manual_idx if manual_idx else state.next_index + ext = os.path.splitext(img_path)[1] + name = f"{state.active_cat}_{idx:03d}{ext}" + + # Check 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} already exists.", type='negative') + name = f"{state.active_cat}_{idx:03d}_copy{ext}" # Simple fallback + + SorterEngine.stage_image(img_path, state.active_cat, name) + + # Auto-increment global if we used the global counter + if manual_idx is None or manual_idx == state.next_index: + state.next_index = idx + 1 + + ui.notify(f"Tagged: {name}", type='positive') + refresh_staged_info() + refresh_ui() + +def action_untag(img_path): + SorterEngine.clear_staged_item(img_path) + refresh_staged_info() + refresh_ui() + +def action_delete(img_path): + SorterEngine.delete_to_trash(img_path) + load_images() # File list changed, must rescan + refresh_ui() + +def action_apply_page(mode="Copy", cleanup="Keep"): + batch = get_current_batch() + SorterEngine.commit_batch(batch, state.output_dir, cleanup, mode) + ui.notify("Page Applied!") + load_images() + refresh_ui() + + +# ========================================== +# 4. UI COMPONENTS +# ========================================== + +def open_zoom_dialog(path): + with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl'): + ui.label(os.path.basename(path)).classes('text-lg font-bold') + # Use the /full_res route for high quality + ui.image(f"/full_res?path={path}").classes('w-full rounded') + ui.button('Close', on_click=dialog.close).classes('w-full') + dialog.open() + +def render_sidebar(): + sidebar_container.clear() + with sidebar_container: + ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2') + + # 1. 5x5 Visual Grid + with ui.grid(columns=5).classes('gap-1 mb-4'): + for i in range(1, 26): + is_used = i in state.index_map + # NiceGUI allows dynamic coloring easily + color = 'green' if is_used else 'grey-3' + + btn = ui.button(str(i), on_click=lambda i=i: set_index(i)) + btn.props(f'color={color} size=sm flat') + # If used, add a green dot visual (or just use button color) + + def set_index(i): + state.next_index = i + idx_input.set_value(i) + + # 2. Controls + categories = SorterEngine.get_categories() or ["Default"] + + def update_cat(e): + state.active_cat = e.value + refresh_staged_info() # Updates the grid map + render_sidebar() # Redraw sidebar to show new map + + ui.select(categories, value=state.active_cat, on_change=update_cat, label="Active Tag").classes('w-full') + + with ui.row().classes('w-full items-end'): + idx_input = ui.number(label="Next #", value=state.next_index, min=1, precision=0).bind_value(state, 'next_index').classes('w-2/3') + ui.button('πŸ”„', on_click=lambda: detect_next()).classes('w-1/4') + + def detect_next(): + used = state.index_map.keys() + state.next_index = max(used) + 1 if used else 1 + idx_input.set_value(state.next_index) + +def render_gallery(): + grid_container.clear() + batch = get_current_batch() + + # Calculate optimal thumbnail size based on columns + # 4 cols -> ~400px, 8 cols -> ~200px + thumb_size = int(1600 / state.grid_cols) + + with grid_container: + # Use Tailwind grid + with ui.grid(columns=state.grid_cols).classes('w-full gap-2'): + for img_path in batch: + is_staged = img_path in state.staged_data + staged_info = state.staged_data.get(img_path) + + # CARD + with ui.card().classes('p-2 no-shadow border border-gray-300'): + + # Header: Name | Zoom | Del + with ui.row().classes('w-full justify-between items-center no-wrap'): + ui.label(os.path.basename(img_path)[:10]).classes('text-xs text-gray-500') + with ui.row().classes('gap-1'): + ui.button(icon='zoom_in', on_click=lambda p=img_path: open_zoom_dialog(p)).props('flat size=sm dense') + ui.button(icon='close', color='red', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense') + + # Image (WebP from Server) + # We encode the path to be URL safe + url = f"/thumbnail?path={img_path}&size={thumb_size}" + ui.image(url).classes('w-full h-auto rounded').props('no-spinner') + + # Status / Action + if is_staged: + ui.label(f"🏷️ {staged_info['cat']}").classes('bg-green-100 text-green-800 text-xs p-1 rounded w-full text-center my-1') + # Extract number for "Untag (#5)" + try: + num = int(staged_info['name'].rsplit('_', 1)[1].split('.')[0]) + label = f"Untag (#{num})" + except: + label = "Untag" + ui.button(label, color='grey', on_click=lambda p=img_path: action_untag(p)).classes('w-full') + else: + # Index Input + Tag Button + with ui.row().classes('w-full no-wrap gap-1 mt-1'): + # Local input for this card + local_idx = ui.number(value=state.next_index, min=1, precision=0).props('dense outlined').classes('w-1/3') + ui.button('Tag', color='primary', + on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value)) + ).classes('w-2/3') + +def render_pagination(): + pagination_container.clear() + total_pages = math.ceil(len(state.all_images) / state.page_size) + if total_pages <= 1: return + + with pagination_container: + with ui.row().classes('w-full justify-center items-center gap-4'): + # Slider + ui.slider(min=0, max=total_pages-1, value=state.page, on_change=lambda e: set_page(e.value)).classes('w-1/3') + + # Buttons (Window) + start = max(0, state.page - 2) + end = min(total_pages, state.page + 3) + + ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat') + + for p in range(start, end): + color = 'primary' if p == state.page else 'grey' + label = str(p + 1) + if p in state.green_dots: label += " 🟒" + ui.button(label, color=color, on_click=lambda p=p: set_page(p)) + + ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat') + +def set_page(idx): + if 0 <= idx < math.ceil(len(state.all_images) / state.page_size): + state.page = idx + refresh_ui() + +def refresh_ui(): + render_sidebar() + render_pagination() + render_gallery() + +# ========================================== +# 5. KEYBOARD SHORTCUTS +# ========================================== +def handle_key(e): + if not e.action.keydown: return + if e.key.arrow_left: set_page(state.page - 1) + if e.key.arrow_right: set_page(state.page + 1) + # Add number keys 1-9 to set category or tag quickly? + +# ========================================== +# 6. MAIN LAYOUT +# ========================================== +# Initialize Data +load_images() + +with ui.header().classes('bg-white text-black border-b border-gray-200'): + with ui.row().classes('w-full items-center justify-between'): + ui.label('πŸ–ΌοΈ NiceGUI Gallery Sorter').classes('text-xl font-bold') + with ui.row().classes('gap-4'): + ui.input('Source', value=state.source_dir).bind_value(state, 'source_dir') + ui.input('Output', value=state.output_dir).bind_value(state, 'output_dir') + ui.button('Load', on_click=load_images) + +with ui.layout(): + # Left Sidebar + with ui.left_drawer(fixed=True, value=True).classes('bg-gray-50 p-4') as drawer: + sidebar_container = ui.column().classes('w-full') + + # Main Content + with ui.column().classes('w-full p-4'): + # Top Pagination + pagination_container = ui.column().classes('w-full items-center mb-4') + + # Gallery Grid + grid_container = ui.column().classes('w-full') + + # Batch Actions Footer + ui.separator().classes('my-8') + with ui.row().classes('w-full justify-center gap-4'): + ui.button('APPLY PAGE', on_click=lambda: action_apply_page()).props('outline') + ui.button('APPLY GLOBAL', color='red', on_click=lambda: ui.notify("Global not impl in demo")).classes('font-bold') + +# Bind Keys +ui.keyboard(on_key=handle_key) + +# Initial Render +refresh_ui() + +# Start App +ui.run(title="Gallery Sorter", port=8080, reload=True) \ No newline at end of file From 091936069ab62b878425e2ea4bdcc0ff7cded749 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 19:26:23 +0100 Subject: [PATCH 03/22] Update gallery_app.py --- gallery_app.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gallery_app.py b/gallery_app.py index 94bd091..4c3eba9 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -335,4 +335,9 @@ ui.keyboard(on_key=handle_key) refresh_ui() # Start App -ui.run(title="Gallery Sorter", port=8080, reload=True) \ No newline at end of file +ui.run( + title="Gallery Sorter", + host="0.0.0.0", # <--- REQUIRED for Docker + port=8080, # <--- NiceGUI default + reload=False # Set True only for development +) \ No newline at end of file From 0b5e9377e4d80db084341c513efe586182e25f87 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 19:31:29 +0100 Subject: [PATCH 04/22] Add start.sh --- start.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 start.sh 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 From dde0e904422da0822354f7e750402a1fb973c4ad Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 19:34:08 +0100 Subject: [PATCH 05/22] Update gallery_app.py --- gallery_app.py | 57 +++++++++++++++++++++++++------------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 4c3eba9..1d19380 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -298,35 +298,40 @@ def handle_key(e): # ========================================== # 6. MAIN LAYOUT # ========================================== -# Initialize Data -load_images() -with ui.header().classes('bg-white text-black border-b border-gray-200'): +# 1. HEADER (Fixed at top) +with ui.header().classes('bg-white text-black border-b border-gray-200').style('height: 60px'): with ui.row().classes('w-full items-center justify-between'): ui.label('πŸ–ΌοΈ NiceGUI Gallery Sorter').classes('text-xl font-bold') + with ui.row().classes('gap-4'): - ui.input('Source', value=state.source_dir).bind_value(state, 'source_dir') - ui.input('Output', value=state.output_dir).bind_value(state, 'output_dir') + # Bind input fields to state + ui.input('Source').bind_value(state, 'source_dir').classes('w-64') + ui.input('Output').bind_value(state, 'output_dir').classes('w-64') ui.button('Load', on_click=load_images) -with ui.layout(): - # Left Sidebar - with ui.left_drawer(fixed=True, value=True).classes('bg-gray-50 p-4') as drawer: - sidebar_container = ui.column().classes('w-full') +# 2. LEFT SIDEBAR (Fixed Drawer) +# value=True means open by default +with ui.left_drawer(value=True).classes('bg-gray-50 p-4 border-r border-gray-200').props('width=300') as drawer: + sidebar_container = ui.column().classes('w-full') - # Main Content - with ui.column().classes('w-full p-4'): - # Top Pagination - pagination_container = ui.column().classes('w-full items-center mb-4') - - # Gallery Grid - grid_container = ui.column().classes('w-full') - - # Batch Actions Footer - ui.separator().classes('my-8') - with ui.row().classes('w-full justify-center gap-4'): - ui.button('APPLY PAGE', on_click=lambda: action_apply_page()).props('outline') - ui.button('APPLY GLOBAL', color='red', on_click=lambda: ui.notify("Global not impl in demo")).classes('font-bold') +# 3. MAIN CONTENT +# We just place this in a column; NiceGUI handles the "main" area automatically +with ui.column().classes('w-full p-4'): + # Top Pagination + pagination_container = ui.column().classes('w-full items-center mb-4') + + # Gallery Grid + grid_container = ui.column().classes('w-full') + + # Batch Actions Footer + ui.separator().classes('my-8') + with ui.row().classes('w-full justify-center gap-4'): + ui.button('APPLY PAGE', on_click=lambda: action_apply_page()).props('outline') + ui.button('APPLY GLOBAL', color='red', on_click=lambda: ui.notify("Global Apply not implemented in demo")).classes('font-bold') + +# Initialize Data +load_images() # Bind Keys ui.keyboard(on_key=handle_key) @@ -335,9 +340,5 @@ ui.keyboard(on_key=handle_key) refresh_ui() # Start App -ui.run( - title="Gallery Sorter", - host="0.0.0.0", # <--- REQUIRED for Docker - port=8080, # <--- NiceGUI default - reload=False # Set True only for development -) \ No newline at end of file +# Note: reload=False is safer for production/docker +ui.run(title="Gallery Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file From b938dc68fa137dc938ce3774c6db6b3d24bfaf64 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 19:36:47 +0100 Subject: [PATCH 06/22] Update gallery_app.py --- gallery_app.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 1d19380..2257d48 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -169,24 +169,35 @@ def render_sidebar(): with sidebar_container: ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2') - # 1. 5x5 Visual Grid + # 1. FETCH & VALIDATE CATEGORIES + categories = SorterEngine.get_categories() + + # Safety: Ensure list isn't empty + if not categories: + categories = ["Default"] + + # Safety: Ensure active_cat exists in the list + if state.active_cat not in categories: + state.active_cat = categories[0] + # We must refresh the staged info if the category changed drastically + refresh_staged_info() + + # 2. 5x5 Visual Grid with ui.grid(columns=5).classes('gap-1 mb-4'): for i in range(1, 26): is_used = i in state.index_map # NiceGUI allows dynamic coloring easily color = 'green' if is_used else 'grey-3' - btn = ui.button(str(i), on_click=lambda i=i: set_index(i)) - btn.props(f'color={color} size=sm flat') - # If used, add a green dot visual (or just use button color) + # We use a closure for the callback + ui.button(str(i), on_click=lambda i=i: set_index(i)) \ + .props(f'color={color} size=sm flat') def set_index(i): state.next_index = i idx_input.set_value(i) - # 2. Controls - categories = SorterEngine.get_categories() or ["Default"] - + # 3. Controls def update_cat(e): state.active_cat = e.value refresh_staged_info() # Updates the grid map @@ -202,7 +213,7 @@ def render_sidebar(): used = state.index_map.keys() state.next_index = max(used) + 1 if used else 1 idx_input.set_value(state.next_index) - + def render_gallery(): grid_container.clear() batch = get_current_batch() From b5794e9db520e9d231955435183c8a76c74b456c Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 19:54:42 +0100 Subject: [PATCH 07/22] Update gallery_app.py --- gallery_app.py | 49 +++++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 2257d48..fe1ae6b 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -187,7 +187,7 @@ def render_sidebar(): for i in range(1, 26): is_used = i in state.index_map # NiceGUI allows dynamic coloring easily - color = 'green' if is_used else 'grey-3' + color = 'green' if is_used else 'grey-8' # We use a closure for the callback ui.button(str(i), on_click=lambda i=i: set_index(i)) \ @@ -310,25 +310,39 @@ def handle_key(e): # 6. MAIN LAYOUT # ========================================== -# 1. HEADER (Fixed at top) -with ui.header().classes('bg-white text-black border-b border-gray-200').style('height: 60px'): - with ui.row().classes('w-full items-center justify-between'): - ui.label('πŸ–ΌοΈ NiceGUI Gallery Sorter').classes('text-xl font-bold') +# 1. HEADER (Adaptive Color + Dark Mode Toggle) +# We removed 'bg-white' so it respects dark mode. +with ui.header().classes('items-center justify-between bg-slate-900 text-white').style('height: 70px'): + + with ui.row().classes('items-center gap-2'): + ui.label('πŸ–ΌοΈ Sorter').classes('text-xl font-bold mr-4') - with ui.row().classes('gap-4'): - # Bind input fields to state - ui.input('Source').bind_value(state, 'source_dir').classes('w-64') - ui.input('Output').bind_value(state, 'output_dir').classes('w-64') - ui.button('Load', on_click=load_images) + # WIDER INPUTS (w-96 is approx 400px) + # We use 'props' to set specific dark mode styling for inputs + ui.input('Source').bind_value(state, 'source_dir') \ + .classes('w-96').props('dark dense outlined') + + ui.input('Output').bind_value(state, 'output_dir') \ + .classes('w-96').props('dark dense outlined') + + ui.button('Load', on_click=load_images).props('dense flat color=white') -# 2. LEFT SIDEBAR (Fixed Drawer) -# value=True means open by default -with ui.left_drawer(value=True).classes('bg-gray-50 p-4 border-r border-gray-200').props('width=300') as drawer: + # Right side: Dark Mode Toggle + with ui.row().classes('items-center'): + # Toggle switch for Dark Mode + dark = ui.dark_mode() + dark.enable() # Enable by default + ui.switch('Dark', value=True, on_change=lambda e: dark.set_value(e.value)) \ + .props('color=green') + +# 2. LEFT SIDEBAR +with ui.left_drawer(value=True).classes('bg-gray-900 p-4 border-r border-gray-700').props('width=320') as drawer: sidebar_container = ui.column().classes('w-full') # 3. MAIN CONTENT -# We just place this in a column; NiceGUI handles the "main" area automatically -with ui.column().classes('w-full p-4'): +# bg-gray-800 ensures the main background is dark +with ui.column().classes('w-full p-4 bg-gray-800 min-h-screen text-white'): + # Top Pagination pagination_container = ui.column().classes('w-full items-center mb-4') @@ -336,9 +350,9 @@ with ui.column().classes('w-full p-4'): grid_container = ui.column().classes('w-full') # Batch Actions Footer - ui.separator().classes('my-8') + ui.separator().classes('my-8 bg-gray-600') with ui.row().classes('w-full justify-center gap-4'): - ui.button('APPLY PAGE', on_click=lambda: action_apply_page()).props('outline') + ui.button('APPLY PAGE', on_click=lambda: action_apply_page()).props('outline color=white') ui.button('APPLY GLOBAL', color='red', on_click=lambda: ui.notify("Global Apply not implemented in demo")).classes('font-bold') # Initialize Data @@ -351,5 +365,4 @@ ui.keyboard(on_key=handle_key) refresh_ui() # Start App -# Note: reload=False is safer for production/docker ui.run(title="Gallery Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file From 1cbad1a3ed03bde7f126b1e4874bdeb171dee1ba Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 19:59:55 +0100 Subject: [PATCH 08/22] Update gallery_app.py --- gallery_app.py | 57 +++++++++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index fe1ae6b..d3bacd1 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -157,11 +157,18 @@ def action_apply_page(mode="Copy", cleanup="Keep"): # ========================================== def open_zoom_dialog(path): - with ui.dialog() as dialog, ui.card().classes('w-full max-w-4xl'): - ui.label(os.path.basename(path)).classes('text-lg font-bold') - # Use the /full_res route for high quality - ui.image(f"/full_res?path={path}").classes('w-full rounded') - ui.button('Close', on_click=dialog.close).classes('w-full') + # 'w-full max-w-screen-xl' makes it nearly full width on large monitors + with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black'): + + # Header Bar inside the dialog + with ui.row().classes('w-full justify-between items-center p-2 bg-gray-900 text-white'): + ui.label(os.path.basename(path)).classes('text-lg font-bold truncate') + ui.button(icon='close', on_click=dialog.close).props('flat round dense color=white') + + # Image area - remove restrictions so it fills the card + # We use a high-res request + ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain') + dialog.open() def render_sidebar(): @@ -306,34 +313,36 @@ def handle_key(e): if e.key.arrow_right: set_page(state.page + 1) # Add number keys 1-9 to set category or tag quickly? -# ========================================== +## ========================================== # 6. MAIN LAYOUT # ========================================== -# 1. HEADER (Adaptive Color + Dark Mode Toggle) -# We removed 'bg-white' so it respects dark mode. -with ui.header().classes('items-center justify-between bg-slate-900 text-white').style('height: 70px'): +# 1. HEADER (Adaptive & Stretchy Inputs) +with ui.header().classes('items-center justify-between bg-slate-900 text-white border-b border-gray-700').style('height: 70px'): - with ui.row().classes('items-center gap-2'): - ui.label('πŸ–ΌοΈ Sorter').classes('text-xl font-bold mr-4') + # We use a single row with 'no-wrap' to keep everything on one line + with ui.row().classes('w-full items-center gap-4 no-wrap'): - # WIDER INPUTS (w-96 is approx 400px) - # We use 'props' to set specific dark mode styling for inputs - ui.input('Source').bind_value(state, 'source_dir') \ - .classes('w-96').props('dark dense outlined') + # Logo + ui.label('πŸ–ΌοΈ Sorter').classes('text-xl font-bold shrink-0') + + # INPUTS: 'flex-grow' makes them stretch to fill ALL available space + # We wrap them in a flexible row so they share the space 50/50 + with ui.row().classes('flex-grow gap-4'): + ui.input('Source').bind_value(state, 'source_dir') \ + .classes('flex-grow').props('dark dense outlined input-class="text-green-300"') + + ui.input('Output').bind_value(state, 'output_dir') \ + .classes('flex-grow').props('dark dense outlined input-class="text-blue-300"') - ui.input('Output').bind_value(state, 'output_dir') \ - .classes('w-96').props('dark dense outlined') - - ui.button('Load', on_click=load_images).props('dense flat color=white') + # Buttons (shrink-0 prevents them from getting squished) + ui.button('Load', on_click=load_images).props('dense flat color=white').classes('shrink-0') - # Right side: Dark Mode Toggle - with ui.row().classes('items-center'): - # Toggle switch for Dark Mode + # Dark Mode Toggle dark = ui.dark_mode() - dark.enable() # Enable by default + dark.enable() ui.switch('Dark', value=True, on_change=lambda e: dark.set_value(e.value)) \ - .props('color=green') + .props('color=green').classes('shrink-0') # 2. LEFT SIDEBAR with ui.left_drawer(value=True).classes('bg-gray-900 p-4 border-r border-gray-700').props('width=320') as drawer: From 588822f8568e290b0be1160e9e30e56b334c9ef3 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 20:03:47 +0100 Subject: [PATCH 09/22] Update gallery_app.py --- gallery_app.py | 358 +++++++++++++++++++------------------------------ 1 file changed, 138 insertions(+), 220 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index d3bacd1..d2c062c 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -10,12 +10,12 @@ from engine import SorterEngine # ========================================== class AppState: def __init__(self): - # Load Defaults + # Load Defaults from engine profiles = SorterEngine.load_profiles() p_data = profiles.get("Default", {}) - self.source_dir = p_data.get("tab5_source", ".") - self.output_dir = p_data.get("tab5_out", ".") + self.source_dir = p_data.get("tab5_source", "/storage") + self.output_dir = p_data.get("tab5_out", "/storage") self.page = 0 self.page_size = 24 @@ -23,114 +23,95 @@ class AppState: self.active_cat = "Default" self.next_index = 1 + # Processing Settings + self.batch_mode = "Copy" + self.cleanup_mode = "Keep" + # Caches self.all_images = [] self.staged_data = {} self.green_dots = set() - self.index_map = {} # For 5x5 sidebar grid + self.index_map = {} # {number: source_path} for sidebar previews state = AppState() # ========================================== -# 2. FAST THUMBNAIL SERVER (The Speed Secret) +# 2. IMAGE SERVING API # ========================================== -# Instead of embedding bytes in HTML (slow), we create a dedicated API -# that serves WebP thumbnails. The browser loads these in parallel. @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400): - """ - Serves a resized WebP thumbnail. - Uses run.cpu_bound to prevent blocking the UI. - """ - if not os.path.exists(path): - return Response(status_code=404) - - # We use a lower quality (q=70) for grid speed - img_bytes = await run.cpu_bound( - SorterEngine.compress_for_web, path, quality=70, target_size=size - ) - - if img_bytes: - return Response(content=img_bytes, media_type="image/webp") - return Response(status_code=500) + if not os.path.exists(path): return Response(status_code=404) + img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 70, 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): - """Serves high-quality image for Zoom/Preview.""" - img_bytes = await run.cpu_bound( - SorterEngine.compress_for_web, path, quality=90, target_size=None - ) + img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None) return Response(content=img_bytes, media_type="image/webp") - # ========================================== # 3. LOGIC & ACTIONS # ========================================== def load_images(): - """Scans folder and updates state.""" if os.path.exists(state.source_dir): state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) refresh_staged_info() + refresh_ui() else: ui.notify(f"Source not found: {state.source_dir}", type='warning') def refresh_staged_info(): - """Refreshes DB data and Green Dots cache.""" state.staged_data = SorterEngine.get_staged_data() - # Calculate Green Dots (Pages with tags) + # 1. Green Dots (Pagination) 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) - # Calculate Sidebar Grid Map (Numbers used in current cat) + # 2. Sidebar Index Map (Used numbers for current category) state.index_map.clear() - # 1. Staging - for info in state.staged_data.values(): + # Check Staging + for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: try: num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) - state.index_map[num] = True + state.index_map[num] = orig_path except: pass - # 2. Disk + # Check Disk cat_path = os.path.join(state.output_dir, state.active_cat) if os.path.exists(cat_path): for f in os.listdir(cat_path): if f.startswith(state.active_cat) and "_" in f: try: num = int(f.rsplit('_', 1)[1].split('.')[0]) - state.index_map[num] = True + if num not in state.index_map: + state.index_map[num] = os.path.join(cat_path, f) except: pass def get_current_batch(): start = state.page * state.page_size return state.all_images[start : start + state.page_size] -async def action_tag(img_path, manual_idx=None): - """Tags an image.""" +def action_tag(img_path, manual_idx=None): idx = manual_idx if manual_idx else state.next_index ext = os.path.splitext(img_path)[1] name = f"{state.active_cat}_{idx:03d}{ext}" - # Check 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} already exists.", type='negative') - name = f"{state.active_cat}_{idx:03d}_copy{ext}" # Simple fallback + ui.notify(f"Conflict! Using suffix for {name}", type='warning') + name = f"{state.active_cat}_{idx:03d}_{idx}{ext}" SorterEngine.stage_image(img_path, state.active_cat, name) - - # Auto-increment global if we used the global counter if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 - ui.notify(f"Tagged: {name}", type='positive') refresh_staged_info() refresh_ui() @@ -141,237 +122,174 @@ def action_untag(img_path): def action_delete(img_path): SorterEngine.delete_to_trash(img_path) - load_images() # File list changed, must rescan - refresh_ui() - -def action_apply_page(mode="Copy", cleanup="Keep"): - batch = get_current_batch() - SorterEngine.commit_batch(batch, state.output_dir, cleanup, mode) - ui.notify("Page Applied!") load_images() - refresh_ui() +def action_apply_page(): + batch = get_current_batch() + 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() + +def action_apply_global(): + ui.notify("Starting Global Apply...") + SorterEngine.commit_global(state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir) + load_images() + ui.notify("Global Apply Complete!", type='positive') # ========================================== -# 4. UI COMPONENTS +# 4. UI RENDERERS # ========================================== -def open_zoom_dialog(path): - # 'w-full max-w-screen-xl' makes it nearly full width on large monitors +def open_zoom_dialog(path, title=None): with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black'): - - # Header Bar inside the dialog with ui.row().classes('w-full justify-between items-center p-2 bg-gray-900 text-white'): - ui.label(os.path.basename(path)).classes('text-lg font-bold truncate') + ui.label(title or os.path.basename(path)).classes('font-bold truncate') ui.button(icon='close', on_click=dialog.close).props('flat round dense color=white') - - # Image area - remove restrictions so it fills the card - # We use a high-res request ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain') - dialog.open() def render_sidebar(): sidebar_container.clear() with sidebar_container: - ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2') + ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') - # 1. FETCH & VALIDATE CATEGORIES - categories = SorterEngine.get_categories() - - # Safety: Ensure list isn't empty - if not categories: - categories = ["Default"] - - # Safety: Ensure active_cat exists in the list - if state.active_cat not in categories: - state.active_cat = categories[0] - # We must refresh the staged info if the category changed drastically - refresh_staged_info() - - # 2. 5x5 Visual Grid - with ui.grid(columns=5).classes('gap-1 mb-4'): + # 1. 5x5 Grid + 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 - # NiceGUI allows dynamic coloring easily - color = 'green' if is_used else 'grey-8' + color = 'green' if is_used else 'grey-9' - # We use a closure for the callback - ui.button(str(i), on_click=lambda i=i: set_index(i)) \ - .props(f'color={color} size=sm flat') - - def set_index(i): - state.next_index = i - idx_input.set_value(i) + def click_grid(num=i): + state.next_index = num + if num in state.index_map: + open_zoom_dialog(state.index_map[num], f"Index #{num}") + else: + ui.notify(f"Next index set to #{num}") + render_sidebar() - # 3. Controls - def update_cat(e): - state.active_cat = e.value - refresh_staged_info() # Updates the grid map - render_sidebar() # Redraw sidebar to show new map - - ui.select(categories, value=state.active_cat, on_change=update_cat, label="Active Tag").classes('w-full') + ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-700') - with ui.row().classes('w-full items-end'): - idx_input = ui.number(label="Next #", value=state.next_index, min=1, precision=0).bind_value(state, 'next_index').classes('w-2/3') - ui.button('πŸ”„', on_click=lambda: detect_next()).classes('w-1/4') + # 2. Category Select + categories = SorterEngine.get_categories() or ["Default"] + if state.active_cat not in categories: state.active_cat = categories[0] + + ui.select(categories, value=state.active_cat, label="Active Category", + on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ + .classes('w-full').props('dark outlined') + + # 3. Add Category + with ui.row().classes('w-full items-center no-wrap mt-2'): + new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('flex-grow') + def add_it(): + 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_it).props('flat color=green') + + # 4. Danger Zone + with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'): + ui.button('DELETE CURRENT CATEGORY', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), refresh_staged_info(), render_sidebar())).classes('w-full') + + ui.separator().classes('my-4 bg-gray-700') + + # 5. Index & Counter + with ui.row().classes('w-full items-end no-wrap'): + ui.number(label="Next #", min=1, precision=0).bind_value(state, 'next_index').classes('flex-grow').props('dark') + ui.button('πŸ”„', on_click=lambda: (setattr(state, 'next_index', (max(state.index_map.keys())+1 if state.index_map else 1)), render_sidebar())).props('flat color=white') - def detect_next(): - used = state.index_map.keys() - state.next_index = max(used) + 1 if used else 1 - idx_input.set_value(state.next_index) - def render_gallery(): grid_container.clear() batch = get_current_batch() - - # Calculate optimal thumbnail size based on columns - # 4 cols -> ~400px, 8 cols -> ~200px thumb_size = int(1600 / state.grid_cols) with grid_container: - # Use Tailwind grid - with ui.grid(columns=state.grid_cols).classes('w-full gap-2'): + with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): for img_path in batch: is_staged = img_path in state.staged_data - staged_info = state.staged_data.get(img_path) - - # CARD - with ui.card().classes('p-2 no-shadow border border-gray-300'): - - # Header: Name | Zoom | Del - with ui.row().classes('w-full justify-between items-center no-wrap'): - ui.label(os.path.basename(img_path)[:10]).classes('text-xs text-gray-500') + with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): + # Header + with ui.row().classes('w-full justify-between no-wrap'): + ui.label(os.path.basename(img_path)[:12]).classes('text-xs text-gray-400 truncate') with ui.row().classes('gap-1'): - ui.button(icon='zoom_in', on_click=lambda p=img_path: open_zoom_dialog(p)).props('flat size=sm dense') - ui.button(icon='close', color='red', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense') + 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') - # Image (WebP from Server) - # We encode the path to be URL safe - url = f"/thumbnail?path={img_path}&size={thumb_size}" - ui.image(url).classes('w-full h-auto rounded').props('no-spinner') + # Image + ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded shadow-lg').props('no-spinner') - # Status / Action + # Tagging Area if is_staged: - ui.label(f"🏷️ {staged_info['cat']}").classes('bg-green-100 text-green-800 text-xs p-1 rounded w-full text-center my-1') - # Extract number for "Untag (#5)" - try: - num = int(staged_info['name'].rsplit('_', 1)[1].split('.')[0]) - label = f"Untag (#{num})" - except: - label = "Untag" - ui.button(label, color='grey', on_click=lambda p=img_path: action_untag(p)).classes('w-full') + info = state.staged_data[img_path] + num = info['name'].rsplit('_', 1)[1].split('.')[0] + ui.label(f"🏷️ {info['cat']} (#{num})").classes('text-center text-green-400 text-xs py-1 bg-green-900/30 rounded mt-2') + ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5').classes('w-full') else: - # Index Input + Tag Button - with ui.row().classes('w-full no-wrap gap-1 mt-1'): - # Local input for this card - local_idx = ui.number(value=state.next_index, min=1, precision=0).props('dense outlined').classes('w-1/3') - ui.button('Tag', color='primary', - on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value)) - ).classes('w-2/3') + 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') def render_pagination(): pagination_container.clear() total_pages = math.ceil(len(state.all_images) / state.page_size) if total_pages <= 1: return - with pagination_container: - with ui.row().classes('w-full justify-center items-center gap-4'): - # Slider - ui.slider(min=0, max=total_pages-1, value=state.page, on_change=lambda e: set_page(e.value)).classes('w-1/3') - - # Buttons (Window) - start = max(0, state.page - 2) - end = min(total_pages, state.page + 3) - - ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat') - - for p in range(start, end): - color = 'primary' if p == state.page else 'grey' - label = str(p + 1) - if p in state.green_dots: label += " 🟒" - ui.button(label, color=color, on_click=lambda p=p: set_page(p)) - - ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat') + with ui.row().classes('items-center gap-2'): + ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat color=white') + for p in range(max(0, state.page-2), min(total_pages, state.page+3)): + dot = " 🟒" if p in state.green_dots else "" + ui.button(f"{p+1}{dot}", on_click=lambda p=p: set_page(p)).props(f'flat color={"white" if p==state.page else "grey-6"}') + ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat color=white') -def set_page(idx): - if 0 <= idx < math.ceil(len(state.all_images) / state.page_size): - state.page = idx - refresh_ui() +def set_page(p): + state.page = p; refresh_ui() def refresh_ui(): - render_sidebar() - render_pagination() - render_gallery() + render_sidebar(); render_pagination(); render_gallery() -# ========================================== -# 5. KEYBOARD SHORTCUTS -# ========================================== def handle_key(e): if not e.action.keydown: return if e.key.arrow_left: set_page(state.page - 1) if e.key.arrow_right: set_page(state.page + 1) - # Add number keys 1-9 to set category or tag quickly? - -## ========================================== -# 6. MAIN LAYOUT + +# ========================================== +# 5. MAIN LAYOUT # ========================================== -# 1. HEADER (Adaptive & Stretchy Inputs) -with ui.header().classes('items-center justify-between bg-slate-900 text-white border-b border-gray-700').style('height: 70px'): - - # We use a single row with 'no-wrap' to keep everything on one line - with ui.row().classes('w-full items-center gap-4 no-wrap'): - - # Logo - ui.label('πŸ–ΌοΈ Sorter').classes('text-xl font-bold shrink-0') - - # INPUTS: 'flex-grow' makes them stretch to fill ALL available space - # We wrap them in a flexible row so they share the space 50/50 - with ui.row().classes('flex-grow gap-4'): - ui.input('Source').bind_value(state, 'source_dir') \ - .classes('flex-grow').props('dark dense outlined input-class="text-green-300"') - - ui.input('Output').bind_value(state, 'output_dir') \ - .classes('flex-grow').props('dark dense outlined input-class="text-blue-300"') - - # Buttons (shrink-0 prevents them from getting squished) - ui.button('Load', on_click=load_images).props('dense flat color=white').classes('shrink-0') +# 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') + 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('LOAD', on_click=load_images).props('color=white flat').classes('font-bold') + ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green') - # Dark Mode Toggle - dark = ui.dark_mode() - dark.enable() - ui.switch('Dark', value=True, on_change=lambda e: dark.set_value(e.value)) \ - .props('color=green').classes('shrink-0') - -# 2. LEFT SIDEBAR -with ui.left_drawer(value=True).classes('bg-gray-900 p-4 border-r border-gray-700').props('width=320') as drawer: +# Sidebar +with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'): sidebar_container = ui.column().classes('w-full') -# 3. MAIN CONTENT -# bg-gray-800 ensures the main background is dark -with ui.column().classes('w-full p-4 bg-gray-800 min-h-screen text-white'): - - # Top Pagination - pagination_container = ui.column().classes('w-full items-center mb-4') - - # Gallery Grid +# Content +with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'): + pagination_container = ui.column().classes('w-full items-center mb-6') grid_container = ui.column().classes('w-full') - # Batch Actions Footer - ui.separator().classes('my-8 bg-gray-600') - with ui.row().classes('w-full justify-center gap-4'): - ui.button('APPLY PAGE', on_click=lambda: action_apply_page()).props('outline color=white') - ui.button('APPLY GLOBAL', color='red', on_click=lambda: ui.notify("Global Apply not implemented in demo")).classes('font-bold') + # Batch Settings + 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'): + with ui.column(): + ui.label('Tagged:').classes('text-gray-500 text-xs uppercase') + ui.radio(['Copy', 'Move'], value=state.batch_mode).bind_value(state, 'batch_mode').props('inline dark color=green') + with ui.column(): + ui.label('Untagged:').classes('text-gray-500 text-xs uppercase') + ui.radio(['Keep', 'Move to Unused', 'Delete'], value=state.cleanup_mode).bind_value(state, 'cleanup_mode').props('inline dark color=green') + with ui.row().classes('items-center gap-4'): + ui.button('APPLY PAGE', on_click=action_apply_page).props('outline color=white lg') + ui.button('APPLY GLOBAL', on_click=action_apply_global).props('lg').classes('bg-red-700 font-bold') -# Initialize Data -load_images() - -# Bind Keys +# Setup ui.keyboard(on_key=handle_key) - -# Initial Render -refresh_ui() - -# Start App -ui.run(title="Gallery Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file +ui.dark_mode().enable() +load_images() +ui.run(title="Nice Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file From 91a0cc51388f26cd8f61eeba354635cbbe3188e7 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 20:06:23 +0100 Subject: [PATCH 10/22] Update gallery_app.py --- gallery_app.py | 167 +++++++++++++++---------------------------------- 1 file changed, 49 insertions(+), 118 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index d2c062c..81f730f 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -10,9 +10,9 @@ from engine import SorterEngine # ========================================== class AppState: def __init__(self): - # Load Defaults from engine - profiles = SorterEngine.load_profiles() - p_data = profiles.get("Default", {}) + self.profiles = SorterEngine.load_profiles() + self.current_profile = "Default" + p_data = self.profiles.get(self.current_profile, {}) self.source_dir = p_data.get("tab5_source", "/storage") self.output_dir = p_data.get("tab5_out", "/storage") @@ -23,22 +23,19 @@ class AppState: self.active_cat = "Default" self.next_index = 1 - # Processing Settings self.batch_mode = "Copy" self.cleanup_mode = "Keep" - # Caches self.all_images = [] self.staged_data = {} self.green_dots = set() - self.index_map = {} # {number: source_path} for sidebar previews + self.index_map = {} state = AppState() # ========================================== # 2. IMAGE SERVING API # ========================================== - @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400): if not os.path.exists(path): return Response(status_code=404) @@ -53,7 +50,6 @@ async def get_full_res(path: str): # ========================================== # 3. LOGIC & ACTIONS # ========================================== - def load_images(): if os.path.exists(state.source_dir): state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) @@ -64,24 +60,19 @@ def load_images(): def refresh_staged_info(): state.staged_data = SorterEngine.get_staged_data() - - # 1. Green Dots (Pagination) 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) - # 2. Sidebar Index Map (Used numbers for current category) state.index_map.clear() - # Check Staging for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: try: num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) state.index_map[num] = orig_path except: pass - # Check Disk cat_path = os.path.join(state.output_dir, state.active_cat) if os.path.exists(cat_path): for f in os.listdir(cat_path): @@ -92,54 +83,29 @@ def refresh_staged_info(): state.index_map[num] = os.path.join(cat_path, f) except: pass -def get_current_batch(): - start = state.page * state.page_size - return state.all_images[start : start + state.page_size] +def save_profile_settings(): + SorterEngine.save_tab_paths(state.current_profile, t5_s=state.source_dir, t5_o=state.output_dir) + ui.notify(f"Settings saved to profile: {state.current_profile}") def action_tag(img_path, manual_idx=None): idx = manual_idx if manual_idx else state.next_index ext = os.path.splitext(img_path)[1] name = f"{state.active_cat}_{idx:03d}{ext}" - 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! Using suffix for {name}", type='warning') + if os.path.exists(final_path): name = f"{state.active_cat}_{idx:03d}_{idx}{ext}" - SorterEngine.stage_image(img_path, state.active_cat, name) if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 - - refresh_staged_info() - refresh_ui() + refresh_staged_info(); refresh_ui() def action_untag(img_path): SorterEngine.clear_staged_item(img_path) - refresh_staged_info() - refresh_ui() - -def action_delete(img_path): - SorterEngine.delete_to_trash(img_path) - load_images() - -def action_apply_page(): - batch = get_current_batch() - 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() - -def action_apply_global(): - ui.notify("Starting Global Apply...") - SorterEngine.commit_global(state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir) - load_images() - ui.notify("Global Apply Complete!", type='positive') + refresh_staged_info(); refresh_ui() # ========================================== -# 4. UI RENDERERS +# 4. UI COMPONENTS # ========================================== - def open_zoom_dialog(path, title=None): 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'): @@ -152,88 +118,38 @@ def render_sidebar(): sidebar_container.clear() with sidebar_container: ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') - - # 1. 5x5 Grid 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 click_grid(num=i): - state.next_index = num - if num in state.index_map: - open_zoom_dialog(state.index_map[num], f"Index #{num}") - else: - ui.notify(f"Next index set to #{num}") - render_sidebar() - - ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-700') + ui.button(str(i), on_click=lambda i=i: (setattr(state, 'next_index', i), (open_zoom_dialog(state.index_map[i], f"Index #{i}") if i in state.index_map else ui.notify(f"Index set to #{i}")), render_sidebar())) \ + .props(f'color={color} size=sm flat').classes('w-full border border-gray-700') - # 2. Category Select categories = SorterEngine.get_categories() or ["Default"] - if state.active_cat not in categories: state.active_cat = categories[0] - ui.select(categories, value=state.active_cat, label="Active Category", on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ .classes('w-full').props('dark outlined') - # 3. Add Category - with ui.row().classes('w-full items-center no-wrap mt-2'): - new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('flex-grow') - def add_it(): - 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_it).props('flat color=green') - - # 4. Danger Zone - with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'): - ui.button('DELETE CURRENT CATEGORY', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), refresh_staged_info(), render_sidebar())).classes('w-full') + with ui.expansion('Manage Categories', icon='settings').classes('w-full text-gray-400 mt-2'): + new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('w-full mb-2') + ui.button('ADD', on_click=lambda: (SorterEngine.add_category(new_cat_input.value), render_sidebar())).classes('w-full mb-4').props('color=green') + + rename_input = ui.input(label='Rename current to:').props('dense outlined dark').classes('w-full mb-2') + ui.button('RENAME', on_click=lambda: (SorterEngine.rename_category(state.active_cat, rename_input.value), setattr(state, 'active_cat', rename_input.value), render_sidebar())).classes('w-full mb-4') + + ui.button('DELETE CURRENT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), render_sidebar())).classes('w-full') ui.separator().classes('my-4 bg-gray-700') - - # 5. Index & Counter with ui.row().classes('w-full items-end no-wrap'): - ui.number(label="Next #", min=1, precision=0).bind_value(state, 'next_index').classes('flex-grow').props('dark') + ui.number(label="Next #", min=1).bind_value(state, 'next_index').classes('flex-grow').props('dark') ui.button('πŸ”„', on_click=lambda: (setattr(state, 'next_index', (max(state.index_map.keys())+1 if state.index_map else 1)), render_sidebar())).props('flat color=white') -def render_gallery(): - grid_container.clear() - batch = get_current_batch() - thumb_size = int(1600 / state.grid_cols) - - with grid_container: - with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): - for img_path in batch: - is_staged = img_path in state.staged_data - with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): - # Header - with ui.row().classes('w-full justify-between no-wrap'): - ui.label(os.path.basename(img_path)[:12]).classes('text-xs text-gray-400 truncate') - with ui.row().classes('gap-1'): - 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') - - # Image - ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded shadow-lg').props('no-spinner') - - # Tagging Area - if is_staged: - info = state.staged_data[img_path] - num = info['name'].rsplit('_', 1)[1].split('.')[0] - ui.label(f"🏷️ {info['cat']} (#{num})").classes('text-center text-green-400 text-xs py-1 bg-green-900/30 rounded mt-2') - ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5').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') - def render_pagination(): pagination_container.clear() total_pages = math.ceil(len(state.all_images) / state.page_size) if total_pages <= 1: return with pagination_container: + ui.slider(min=0, max=total_pages-1, step=1, value=state.page, on_change=lambda e: set_page(e.value)).classes('w-64') with ui.row().classes('items-center gap-2'): ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat color=white') for p in range(max(0, state.page-2), min(total_pages, state.page+3)): @@ -241,6 +157,30 @@ def render_pagination(): ui.button(f"{p+1}{dot}", on_click=lambda p=p: set_page(p)).props(f'flat color={"white" if p==state.page else "grey-6"}') ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat color=white') +def render_gallery(): + grid_container.clear() + batch = state.all_images[state.page * state.page_size : (state.page+1) * state.page_size] + thumb_size = int(1600 / state.grid_cols) + with grid_container: + with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): + for img_path in batch: + is_staged = img_path in state.staged_data + with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): + with ui.row().classes('w-full justify-between no-wrap'): + ui.label(os.path.basename(img_path)[:12]).classes('text-xs text-gray-400 truncate') + with ui.row().classes('gap-1'): + 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: SorterEngine.delete_to_trash(p) or load_images()).props('flat size=sm dense color=red') + ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded shadow-lg') + if is_staged: + info = state.staged_data[img_path] + ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 bg-green-900/30 rounded mt-2') + ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5').classes('w-full') + else: + with ui.row().classes('w-full no-wrap mt-2 gap-1'): + li = 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=li: action_tag(p, int(i.value))).classes('w-2/3').props('color=green') + def set_page(p): state.page = p; refresh_ui() @@ -255,40 +195,31 @@ def handle_key(e): # ========================================== # 5. MAIN LAYOUT # ========================================== - -# 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') 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('SAVE', on_click=save_profile_settings).props('color=blue flat') ui.button('LOAD', on_click=load_images).props('color=white flat').classes('font-bold') - ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green') -# Sidebar with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'): sidebar_container = ui.column().classes('w-full') -# Content with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'): pagination_container = ui.column().classes('w-full items-center mb-6') grid_container = ui.column().classes('w-full') - - # Batch Settings 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'): with ui.column(): - ui.label('Tagged:').classes('text-gray-500 text-xs uppercase') ui.radio(['Copy', 'Move'], value=state.batch_mode).bind_value(state, 'batch_mode').props('inline dark color=green') with ui.column(): - ui.label('Untagged:').classes('text-gray-500 text-xs uppercase') ui.radio(['Keep', 'Move to Unused', 'Delete'], value=state.cleanup_mode).bind_value(state, 'cleanup_mode').props('inline dark color=green') with ui.row().classes('items-center gap-4'): ui.button('APPLY PAGE', on_click=action_apply_page).props('outline color=white lg') - ui.button('APPLY GLOBAL', on_click=action_apply_global).props('lg').classes('bg-red-700 font-bold') + ui.button('APPLY GLOBAL', on_click=lambda: (SorterEngine.commit_global(state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir), load_images())).props('lg').classes('bg-red-700') -# Setup ui.keyboard(on_key=handle_key) ui.dark_mode().enable() load_images() From 3e9ff43bc95a9b694a97621b07a604fe7abc75a1 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 20:08:03 +0100 Subject: [PATCH 11/22] Update gallery_app.py --- gallery_app.py | 207 +++++++++++++++++++++++++++++-------------------- 1 file changed, 122 insertions(+), 85 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 81f730f..9d566cf 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -9,10 +9,10 @@ from engine import SorterEngine # 1. STATE MANAGEMENT # ========================================== class AppState: - def __init__(self): - self.profiles = SorterEngine.load_profiles() - self.current_profile = "Default" - p_data = self.profiles.get(self.current_profile, {}) + def __init__(self, profile_name="Default"): + self.profile_name = profile_name + profiles = SorterEngine.load_profiles() + p_data = profiles.get(profile_name, {}) self.source_dir = p_data.get("tab5_source", "/storage") self.output_dir = p_data.get("tab5_out", "/storage") @@ -29,13 +29,14 @@ class AppState: self.all_images = [] self.staged_data = {} self.green_dots = set() - self.index_map = {} + self.index_map = {} # {number: path} for previews state = AppState() # ========================================== # 2. IMAGE SERVING API # ========================================== + @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400): if not os.path.exists(path): return Response(status_code=404) @@ -44,12 +45,18 @@ async def get_thumbnail(path: str, size: int = 400): @app.get('/full_res') async def get_full_res(path: str): - img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None) + img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 95, None) return Response(content=img_bytes, media_type="image/webp") # ========================================== # 3. LOGIC & ACTIONS # ========================================== + +def save_profile_settings(): + """Feature 5: Saves current paths to profiles.json""" + SorterEngine.save_tab_paths(state.profile_name, t5_s=state.source_dir, t5_o=state.output_dir) + ui.notify("Profile Saved!", type='positive') + def load_images(): if os.path.exists(state.source_dir): state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) @@ -60,12 +67,15 @@ def load_images(): def refresh_staged_info(): state.staged_data = SorterEngine.get_staged_data() + + # Calculate Pagination Dots 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) + # Calculate Sidebar Index Map state.index_map.clear() for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: @@ -73,6 +83,7 @@ def refresh_staged_info(): num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) state.index_map[num] = orig_path except: pass + cat_path = os.path.join(state.output_dir, state.active_cat) if os.path.exists(cat_path): for f in os.listdir(cat_path): @@ -83,144 +94,170 @@ def refresh_staged_info(): state.index_map[num] = os.path.join(cat_path, f) except: pass -def save_profile_settings(): - SorterEngine.save_tab_paths(state.current_profile, t5_s=state.source_dir, t5_o=state.output_dir) - ui.notify(f"Settings saved to profile: {state.current_profile}") +def get_current_batch(): + start = state.page * state.page_size + return state.all_images[start : start + state.page_size] def action_tag(img_path, manual_idx=None): idx = manual_idx if manual_idx else state.next_index ext = os.path.splitext(img_path)[1] name = f"{state.active_cat}_{idx:03d}{ext}" - final_path = os.path.join(state.output_dir, state.active_cat, name) - if os.path.exists(final_path): + + if os.path.exists(os.path.join(state.output_dir, state.active_cat, name)): name = f"{state.active_cat}_{idx:03d}_{idx}{ext}" + SorterEngine.stage_image(img_path, state.active_cat, name) if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 - refresh_staged_info(); refresh_ui() + + refresh_staged_info() + refresh_ui() def action_untag(img_path): SorterEngine.clear_staged_item(img_path) - refresh_staged_info(); refresh_ui() + refresh_staged_info() + refresh_ui() + +def action_delete(img_path): + SorterEngine.delete_to_trash(img_path) + load_images() # ========================================== # 4. UI COMPONENTS # ========================================== + def open_zoom_dialog(path, title=None): - 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') + with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black overflow-hidden'): + with ui.row().classes('w-full justify-between items-center p-3 bg-gray-900 text-white'): + ui.label(title or os.path.basename(path)).classes('font-bold text-lg') 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') + ui.image(f"/full_res?path={path}").classes('w-full h-auto max-h-[85vh] object-contain') dialog.open() def render_sidebar(): sidebar_container.clear() with sidebar_container: ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') + + # Feature 4: Grid Previews 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' - ui.button(str(i), on_click=lambda i=i: (setattr(state, 'next_index', i), (open_zoom_dialog(state.index_map[i], f"Index #{i}") if i in state.index_map else ui.notify(f"Index set to #{i}")), render_sidebar())) \ - .props(f'color={color} size=sm flat').classes('w-full border border-gray-700') + + def click_grid(num=i): + state.next_index = num + if num in state.index_map: + # Opens high-res preview of what's already tagged + open_zoom_dialog(state.index_map[num], f"Preview Index #{num}") + render_sidebar() + + ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800') - categories = SorterEngine.get_categories() or ["Default"] - ui.select(categories, value=state.active_cat, label="Active Category", - on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ - .classes('w-full').props('dark outlined') + cats = SorterEngine.get_categories() or ["Default"] + ui.select(cats, value=state.active_cat, on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ + .classes('w-full').props('dark outlined label="Active Category"') - with ui.expansion('Manage Categories', icon='settings').classes('w-full text-gray-400 mt-2'): - new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('w-full mb-2') - ui.button('ADD', on_click=lambda: (SorterEngine.add_category(new_cat_input.value), render_sidebar())).classes('w-full mb-4').props('color=green') - - rename_input = ui.input(label='Rename current to:').props('dense outlined dark').classes('w-full mb-2') - ui.button('RENAME', on_click=lambda: (SorterEngine.rename_category(state.active_cat, rename_input.value), setattr(state, 'active_cat', rename_input.value), render_sidebar())).classes('w-full mb-4') - - ui.button('DELETE CURRENT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), render_sidebar())).classes('w-full') + with ui.row().classes('w-full items-center no-wrap mt-2'): + new_cat = ui.input(placeholder='Add...').props('dense outlined dark').classes('flex-grow') + ui.button(icon='add', on_click=lambda: (SorterEngine.add_category(new_cat.value), render_sidebar())).props('flat color=green') - ui.separator().classes('my-4 bg-gray-700') - with ui.row().classes('w-full items-end no-wrap'): - ui.number(label="Next #", min=1).bind_value(state, 'next_index').classes('flex-grow').props('dark') + with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400'): + ui.button('DELETE CAT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), render_sidebar())).classes('w-full') + + ui.separator().classes('my-4 bg-gray-800') + with ui.row().classes('w-full items-center no-wrap'): + ui.number(label="Next #").bind_value(state, 'next_index').classes('flex-grow').props('dark dense') ui.button('πŸ”„', on_click=lambda: (setattr(state, 'next_index', (max(state.index_map.keys())+1 if state.index_map else 1)), render_sidebar())).props('flat color=white') +def render_gallery(): + grid_container.clear() + batch = get_current_batch() + thumb_size = int(1600 / state.grid_cols) + + with grid_container: + with ui.grid(columns=state.grid_cols).classes('w-full gap-4'): + for img_path in batch: + is_staged = img_path in state.staged_data + with ui.card().classes('p-2 bg-slate-900 border border-slate-700 no-shadow'): + with ui.row().classes('w-full justify-between items-center no-wrap mb-1'): + ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-slate-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 color=white') + ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm color=red') + + ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-56 object-cover rounded shadow-md').props('no-spinner') + + if is_staged: + info = state.staged_data[img_path] + ui.label(f"🏷️ {info['name']}").classes('text-center text-green-400 text-xs py-1 mt-2 bg-green-950/40 rounded') + ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-4 w-full') + else: + with ui.row().classes('w-full no-wrap mt-2 gap-1'): + l_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=l_idx: action_tag(p, int(i.value))).classes('flex-grow').props('color=green-7') + def render_pagination(): pagination_container.clear() total_pages = math.ceil(len(state.all_images) / state.page_size) if total_pages <= 1: return + with pagination_container: - ui.slider(min=0, max=total_pages-1, step=1, value=state.page, on_change=lambda e: set_page(e.value)).classes('w-64') - with ui.row().classes('items-center gap-2'): + # Debounced Slider for rapid navigation + ui.slider(min=0, max=total_pages-1, value=state.page, + on_change=lambda e: set_page(e.value)).classes('w-96 mb-2').props('dark') + + with ui.row().classes('items-center gap-1'): ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat color=white') + # Responsive page window for p in range(max(0, state.page-2), min(total_pages, state.page+3)): - dot = " 🟒" if p in state.green_dots else "" - ui.button(f"{p+1}{dot}", on_click=lambda p=p: set_page(p)).props(f'flat color={"white" if p==state.page else "grey-6"}') + label = f"{p+1}{' 🟒' if p in state.green_dots else ''}" + ui.button(label, on_click=lambda p=p: set_page(p)).props(f'flat color={"green" if p==state.page else "white"}') ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat color=white') -def render_gallery(): - grid_container.clear() - batch = state.all_images[state.page * state.page_size : (state.page+1) * state.page_size] - thumb_size = int(1600 / state.grid_cols) - with grid_container: - with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): - for img_path in batch: - is_staged = img_path in state.staged_data - with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): - with ui.row().classes('w-full justify-between no-wrap'): - ui.label(os.path.basename(img_path)[:12]).classes('text-xs text-gray-400 truncate') - with ui.row().classes('gap-1'): - 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: SorterEngine.delete_to_trash(p) or load_images()).props('flat size=sm dense color=red') - ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded shadow-lg') - if is_staged: - info = state.staged_data[img_path] - ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 bg-green-900/30 rounded mt-2') - ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5').classes('w-full') - else: - with ui.row().classes('w-full no-wrap mt-2 gap-1'): - li = 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=li: action_tag(p, int(i.value))).classes('w-2/3').props('color=green') - def set_page(p): - state.page = p; refresh_ui() + state.page = max(0, min(p, math.ceil(len(state.all_images)/state.page_size)-1)) + refresh_ui() def refresh_ui(): render_sidebar(); render_pagination(); render_gallery() -def handle_key(e): - if not e.action.keydown: return - if e.key.arrow_left: set_page(state.page - 1) - if e.key.arrow_right: set_page(state.page + 1) - # ========================================== # 5. MAIN LAYOUT # ========================================== -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') - 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('SAVE', on_click=save_profile_settings).props('color=blue flat') - ui.button('LOAD', on_click=load_images).props('color=white flat').classes('font-bold') -with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'): +with ui.header().classes('items-center bg-slate-950 text-white border-b border-slate-800 px-4').style('height: 75px'): + with ui.row().classes('w-full items-center gap-4 no-wrap'): + ui.label('πŸ–ΌοΈ NiceSorter').classes('text-2xl font-black text-green-500 italic shrink-0') + with ui.row().classes('flex-grow gap-2'): + ui.input('Source Path').bind_value(state, 'source_dir').classes('flex-grow').props('dark dense outlined') + ui.input('Output Path').bind_value(state, 'output_dir').classes('flex-grow').props('dark dense outlined') + ui.button('LOAD', on_click=load_images).props('color=white flat').classes('font-bold px-4') + ui.button(icon='save', on_click=save_profile_settings).props('flat color=white').tooltip('Save Paths to Profile') + ui.switch(value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green') + +with ui.left_drawer(value=True).classes('bg-slate-950 p-4 border-r border-slate-900').props('width=320'): sidebar_container = ui.column().classes('w-full') -with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'): - pagination_container = ui.column().classes('w-full items-center mb-6') +with ui.column().classes('w-full p-6 bg-slate-900 min-h-screen text-white'): + pagination_container = ui.column().classes('w-full items-center mb-8') grid_container = ui.column().classes('w-full') - 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'): + + # Feature 3: Global Apply Fully Integrated + ui.separator().classes('my-12 bg-slate-800') + with ui.row().classes('w-full justify-around p-8 bg-slate-950 rounded-2xl border border-slate-800 shadow-2xl'): with ui.column(): + ui.label('Tagged Action:').classes('text-slate-500 text-xs uppercase font-bold mb-2') ui.radio(['Copy', 'Move'], value=state.batch_mode).bind_value(state, 'batch_mode').props('inline dark color=green') with ui.column(): + ui.label('Cleanup Strategy:').classes('text-slate-500 text-xs uppercase font-bold mb-2') ui.radio(['Keep', 'Move to Unused', 'Delete'], value=state.cleanup_mode).bind_value(state, 'cleanup_mode').props('inline dark color=green') - with ui.row().classes('items-center gap-4'): - ui.button('APPLY PAGE', on_click=action_apply_page).props('outline color=white lg') - ui.button('APPLY GLOBAL', on_click=lambda: (SorterEngine.commit_global(state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir), load_images())).props('lg').classes('bg-red-700') + with ui.row().classes('items-center gap-6'): + ui.button('APPLY PAGE', on_click=lambda: (SorterEngine.commit_batch(get_current_batch(), state.output_dir, state.cleanup_mode, state.batch_mode), load_images())).props('outline color=white lg').classes('px-8') + ui.button('APPLY GLOBAL', on_click=lambda: (SorterEngine.commit_global(state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir), load_images())).props('lg color=red-7').classes('font-black px-12') -ui.keyboard(on_key=handle_key) +# Init & Hotkeys +ui.keyboard(on_key=lambda e: (set_page(state.page-1) if e.key.arrow_left and e.action.keydown else set_page(state.page+1) if e.key.arrow_right and e.action.keydown else None)) ui.dark_mode().enable() load_images() ui.run(title="Nice Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file From 690aaafacf17d53e9a1cca615d6fc74dff65fff0 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 20:21:46 +0100 Subject: [PATCH 12/22] Update gallery_app.py --- gallery_app.py | 313 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 228 insertions(+), 85 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 9d566cf..75e82a1 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -1,6 +1,7 @@ import os import math import asyncio +import json from nicegui import ui, app, run from fastapi import Response from engine import SorterEngine @@ -9,27 +10,50 @@ from engine import SorterEngine # 1. STATE MANAGEMENT # ========================================== class AppState: - def __init__(self, profile_name="Default"): - self.profile_name = profile_name - profiles = SorterEngine.load_profiles() - p_data = profiles.get(profile_name, {}) + 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.source_dir = p_data.get("tab5_source", "/storage") - self.output_dir = p_data.get("tab5_out", "/storage") + # Load initial paths + self.load_active_profile() + # Navigation State self.page = 0 self.page_size = 24 self.grid_cols = 4 + + # 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 = [] self.staged_data = {} self.green_dots = set() - self.index_map = {} # {number: path} for previews + self.index_map = {} # {number: path_to_image} + + def load_active_profile(self): + 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): + # Update local dict + 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 + + # Persist to disk via Engine + 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') state = AppState() @@ -40,26 +64,27 @@ state = AppState() @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400): if not os.path.exists(path): return Response(status_code=404) + # CPU bound to prevent UI freeze img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 70, 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): - img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 95, None) + 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") # ========================================== -# 3. LOGIC & ACTIONS +# 3. CORE LOGIC # ========================================== -def save_profile_settings(): - """Feature 5: Saves current paths to profiles.json""" - SorterEngine.save_tab_paths(state.profile_name, t5_s=state.source_dir, t5_o=state.output_dir) - ui.notify("Profile Saved!", type='positive') - def load_images(): if os.path.exists(state.source_dir): state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) + # Reset page if out of bounds + total_pages = math.ceil(len(state.all_images) / state.page_size) + if state.page >= total_pages: state.page = 0 + refresh_staged_info() refresh_ui() else: @@ -68,45 +93,60 @@ def load_images(): def refresh_staged_info(): state.staged_data = SorterEngine.get_staged_data() - # Calculate Pagination Dots + # 1. Update Green Dots (Pagination) 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) - # Calculate Sidebar Index Map + # 2. Update Sidebar Index Map state.index_map.clear() + + # A. Check Staging (Memory) for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: try: + # Parse "CatName_005.jpg" num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) - state.index_map[num] = orig_path + state.index_map[num] = orig_path # Point to original for preview except: pass + # B. Check Disk (Output Folder) cat_path = os.path.join(state.output_dir, state.active_cat) if os.path.exists(cat_path): for f in os.listdir(cat_path): if f.startswith(state.active_cat) and "_" in f: try: num = int(f.rsplit('_', 1)[1].split('.')[0]) + # Staging takes precedence, otherwise add disk file if num not in state.index_map: state.index_map[num] = os.path.join(cat_path, f) except: pass def get_current_batch(): + if not state.all_images: return [] start = state.page * state.page_size return state.all_images[start : start + state.page_size] +# --- ACTIONS --- + def action_tag(img_path, manual_idx=None): idx = manual_idx if manual_idx else state.next_index ext = os.path.splitext(img_path)[1] name = f"{state.active_cat}_{idx:03d}{ext}" - if os.path.exists(os.path.join(state.output_dir, state.active_cat, name)): - name = f"{state.active_cat}_{idx:03d}_{idx}{ext}" + # Conflict Check + 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) + + # Auto-increment if we used the global counter if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 @@ -120,18 +160,37 @@ def action_untag(img_path): def action_delete(img_path): SorterEngine.delete_to_trash(img_path) + load_images() # Rescan needed + +def action_apply_page(): + batch = get_current_batch() + if not batch: 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() +def action_apply_global(): + ui.notify("Starting Global Apply... This may take a moment.") + # Run in background to keep UI responsive + run.io_bound(SorterEngine.commit_global, state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir) + # We reload after a short delay or assume user will click reload + load_images() + ui.notify("Global Apply Complete!", type='positive') + + # ========================================== # 4. UI COMPONENTS # ========================================== def open_zoom_dialog(path, title=None): - with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black overflow-hidden'): - with ui.row().classes('w-full justify-between items-center p-3 bg-gray-900 text-white'): - ui.label(title or os.path.basename(path)).classes('font-bold text-lg') + """Shows full screen image modal""" + 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') 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 max-h-[85vh] object-contain') + + # Full Res Image + ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain max-h-[85vh]') dialog.open() def render_sidebar(): @@ -139,125 +198,209 @@ def render_sidebar(): with sidebar_container: ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') - # Feature 4: Grid Previews + # --- FEATURE 4: SIDEBAR PREVIEW GRID --- 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 click_grid(num=i): + # Closure to capture 'i' and 'is_used' + def click_grid(num=i, used=is_used): state.next_index = num - if num in state.index_map: - # Opens high-res preview of what's already tagged - open_zoom_dialog(state.index_map[num], f"Preview Index #{num}") - render_sidebar() + if used: + # Feature 4: Open Preview + open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") + render_sidebar() # Update Next # input ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800') - cats = SorterEngine.get_categories() or ["Default"] - ui.select(cats, value=state.active_cat, on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ - .classes('w-full').props('dark outlined label="Active Category"') + # --- CATEGORY SELECTOR --- + categories = SorterEngine.get_categories() or ["Default"] + if state.active_cat not in categories: state.active_cat = categories[0] + def change_cat(e): + state.active_cat = e.value + refresh_staged_info() + render_sidebar() + + ui.select(categories, value=state.active_cat, label="Active Category", on_change=change_cat) \ + .classes('w-full').props('dark outlined') + + # --- ADD CATEGORY --- with ui.row().classes('w-full items-center no-wrap mt-2'): - new_cat = ui.input(placeholder='Add...').props('dense outlined dark').classes('flex-grow') - ui.button(icon='add', on_click=lambda: (SorterEngine.add_category(new_cat.value), render_sidebar())).props('flat color=green') + new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('flex-grow') + def add_it(): + 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_it).props('flat color=green') - with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400'): - ui.button('DELETE CAT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), render_sidebar())).classes('w-full') + # --- DELETE CATEGORY --- + with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'): + ui.button('DELETE CAT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), refresh_staged_info(), render_sidebar())).classes('w-full') - ui.separator().classes('my-4 bg-gray-800') - with ui.row().classes('w-full items-center no-wrap'): - ui.number(label="Next #").bind_value(state, 'next_index').classes('flex-grow').props('dark dense') - ui.button('πŸ”„', on_click=lambda: (setattr(state, 'next_index', (max(state.index_map.keys())+1 if state.index_map else 1)), render_sidebar())).props('flat color=white') + 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 auto_detect(): + used = state.index_map.keys() + state.next_index = max(used) + 1 if used else 1 + ui.button('πŸ”„', on_click=lambda: (auto_detect(), render_sidebar())).props('flat color=white') def render_gallery(): grid_container.clear() batch = get_current_batch() + # Dynamic thumbnail sizing thumb_size = int(1600 / state.grid_cols) with grid_container: - with ui.grid(columns=state.grid_cols).classes('w-full gap-4'): + with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): for img_path in batch: is_staged = img_path in state.staged_data - with ui.card().classes('p-2 bg-slate-900 border border-slate-700 no-shadow'): - with ui.row().classes('w-full justify-between items-center no-wrap mb-1'): - ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-slate-400 truncate') + + with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): + # Header + 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 color=white') - ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm color=red') + 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') - ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-56 object-cover rounded shadow-md').props('no-spinner') + # Image + ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded').props('no-spinner') + # Actions if is_staged: info = state.staged_data[img_path] - ui.label(f"🏷️ {info['name']}").classes('text-center text-green-400 text-xs py-1 mt-2 bg-green-950/40 rounded') - ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-4 w-full') + # Extract number for "Untag (#5)" + try: + num = info['name'].rsplit('_', 1)[1].split('.')[0] + label = f"Untag (#{num})" + except: + label = "Untag" + + ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full') + ui.button(label, 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'): - l_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=l_idx: action_tag(p, int(i.value))).classes('flex-grow').props('color=green-7') + # Local index input + 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(): pagination_container.clear() + if not state.all_images: return + total_pages = math.ceil(len(state.all_images) / state.page_size) if total_pages <= 1: return - + with pagination_container: - # Debounced Slider for rapid navigation - ui.slider(min=0, max=total_pages-1, value=state.page, - on_change=lambda e: set_page(e.value)).classes('w-96 mb-2').props('dark') - - with ui.row().classes('items-center gap-1'): - ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat color=white') - # Responsive page window - for p in range(max(0, state.page-2), min(total_pages, state.page+3)): - label = f"{p+1}{' 🟒' if p in state.green_dots else ''}" - ui.button(label, on_click=lambda p=p: set_page(p)).props(f'flat color={"green" if p==state.page else "white"}') - ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat color=white') + # --- 1. SLIDER (Restored) --- + def on_slide(e): + state.page = int(e.value) + refresh_ui() + + ui.slider(min=0, max=total_pages-1, value=state.page, on_change=on_slide).classes('w-1/2 mb-2').props('color=green') + + # --- 2. CAROUSEL BUTTONS --- + with ui.row().classes('items-center gap-2'): + ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x > 0) + + # Show window of 5 pages + start = max(0, state.page - 2) + end = min(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 p=p: set_page(p)).props(f'flat color={color}') + + ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x < total_pages - 1) def set_page(p): - state.page = max(0, min(p, math.ceil(len(state.all_images)/state.page_size)-1)) + state.page = p refresh_ui() def refresh_ui(): - render_sidebar(); render_pagination(); render_gallery() + render_sidebar() + render_pagination() + render_gallery() + +def handle_key(e): + if not e.action.keydown: return + if e.key.arrow_left: set_page(max(0, state.page - 1)) + if e.key.arrow_right: + total = math.ceil(len(state.all_images) / state.page_size) + set_page(min(total - 1, state.page + 1)) # ========================================== # 5. MAIN LAYOUT # ========================================== -with ui.header().classes('items-center bg-slate-950 text-white border-b border-slate-800 px-4').style('height: 75px'): - with ui.row().classes('w-full items-center gap-4 no-wrap'): - ui.label('πŸ–ΌοΈ NiceSorter').classes('text-2xl font-black text-green-500 italic shrink-0') +# 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') + + # --- FEATURE 5: PROFILE MANAGER --- + # Profile Select + 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') + + # Paths (Flex Grow) with ui.row().classes('flex-grow gap-2'): - ui.input('Source Path').bind_value(state, 'source_dir').classes('flex-grow').props('dark dense outlined') - ui.input('Output Path').bind_value(state, 'output_dir').classes('flex-grow').props('dark dense outlined') - ui.button('LOAD', on_click=load_images).props('color=white flat').classes('font-bold px-4') - ui.button(icon='save', on_click=save_profile_settings).props('flat color=white').tooltip('Save Paths to Profile') - ui.switch(value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green') + 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') + + # Save Profile Button + ui.button(icon='save', on_click=state.save_current_profile).props('flat round color=white').tooltip('Save Paths to Profile') + + # Load Button + ui.button('LOAD FILES', on_click=load_images).props('color=green flat').classes('font-bold border border-green-700') + + # Dark Toggle + ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green') -with ui.left_drawer(value=True).classes('bg-slate-950 p-4 border-r border-slate-900').props('width=320'): +# SIDEBAR +with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'): sidebar_container = ui.column().classes('w-full') -with ui.column().classes('w-full p-6 bg-slate-900 min-h-screen text-white'): - pagination_container = ui.column().classes('w-full items-center mb-8') +# CONTENT +with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'): + pagination_container = ui.column().classes('w-full items-center mb-4') grid_container = ui.column().classes('w-full') - # Feature 3: Global Apply Fully Integrated - ui.separator().classes('my-12 bg-slate-800') - with ui.row().classes('w-full justify-around p-8 bg-slate-950 rounded-2xl border border-slate-800 shadow-2xl'): + # --- FOOTER: BATCH SETTINGS & GLOBAL APPLY --- + 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'): + # Settings with ui.column(): - ui.label('Tagged Action:').classes('text-slate-500 text-xs uppercase font-bold mb-2') + 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') + with ui.column(): - ui.label('Cleanup Strategy:').classes('text-slate-500 text-xs uppercase font-bold mb-2') + 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=lambda: (SorterEngine.commit_batch(get_current_batch(), state.output_dir, state.cleanup_mode, state.batch_mode), load_images())).props('outline color=white lg').classes('px-8') - ui.button('APPLY GLOBAL', on_click=lambda: (SorterEngine.commit_global(state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir), load_images())).props('lg color=red-7').classes('font-black px-12') + ui.button('APPLY PAGE', on_click=action_apply_page).props('outline color=white lg') + + # Feature 3: Global Apply + 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') -# Init & Hotkeys -ui.keyboard(on_key=lambda e: (set_page(state.page-1) if e.key.arrow_left and e.action.keydown else set_page(state.page+1) if e.key.arrow_right and e.action.keydown else None)) +# STARTUP +ui.keyboard(on_key=handle_key) ui.dark_mode().enable() load_images() ui.run(title="Nice Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file From 4fb038eda13e5be2bcef62d5d72e4ae3ea45a74e Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 20:24:32 +0100 Subject: [PATCH 13/22] Update gallery_app.py --- gallery_app.py | 128 +++++++++++++++++++------------------------------ 1 file changed, 50 insertions(+), 78 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 75e82a1..91c39d1 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -20,10 +20,11 @@ class AppState: # Load initial paths self.load_active_profile() - # Navigation State + # View Settings self.page = 0 self.page_size = 24 self.grid_cols = 4 + self.preview_quality = 50 # Default compression level # Tagging State self.active_cat = "Default" @@ -45,32 +46,33 @@ class AppState: self.output_dir = p_data.get("tab5_out", "/storage") def save_current_profile(self): - # Update local dict 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 - - # Persist to disk via Engine 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') state = AppState() # ========================================== -# 2. IMAGE SERVING API +# 2. IMAGE SERVING API (Dynamic Quality) # ========================================== @app.get('/thumbnail') -async def get_thumbnail(path: str, size: int = 400): +async def get_thumbnail(path: str, size: int = 400, q: int = 50): + """ + Serves WebP thumbnail. + 'q' parameter allows dynamic quality adjustment from the UI. + """ if not os.path.exists(path): return Response(status_code=404) - # CPU bound to prevent UI freeze - img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 70, size) + 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): if not os.path.exists(path): return Response(status_code=404) + # Full res always uses high quality (90) img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None) return Response(content=img_bytes, media_type="image/webp") @@ -81,7 +83,7 @@ async def get_full_res(path: str): def load_images(): if os.path.exists(state.source_dir): state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) - # Reset page if out of bounds + # Safety check for page bounds total_pages = math.ceil(len(state.all_images) / state.page_size) if state.page >= total_pages: state.page = 0 @@ -93,7 +95,7 @@ def load_images(): def refresh_staged_info(): state.staged_data = SorterEngine.get_staged_data() - # 1. Update Green Dots (Pagination) + # 1. Update Green Dots state.green_dots.clear() staged_keys = set(state.staged_data.keys()) for idx, img_path in enumerate(state.all_images): @@ -103,23 +105,21 @@ def refresh_staged_info(): # 2. Update Sidebar Index Map state.index_map.clear() - # A. Check Staging (Memory) + # Check Staging for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: try: - # Parse "CatName_005.jpg" num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) - state.index_map[num] = orig_path # Point to original for preview + state.index_map[num] = orig_path except: pass - # B. Check Disk (Output Folder) + # Check Disk cat_path = os.path.join(state.output_dir, state.active_cat) if os.path.exists(cat_path): for f in os.listdir(cat_path): if f.startswith(state.active_cat) and "_" in f: try: num = int(f.rsplit('_', 1)[1].split('.')[0]) - # Staging takes precedence, otherwise add disk file if num not in state.index_map: state.index_map[num] = os.path.join(cat_path, f) except: pass @@ -146,7 +146,6 @@ def action_tag(img_path, manual_idx=None): SorterEngine.stage_image(img_path, state.active_cat, name) - # Auto-increment if we used the global counter if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 @@ -160,7 +159,7 @@ def action_untag(img_path): def action_delete(img_path): SorterEngine.delete_to_trash(img_path) - load_images() # Rescan needed + load_images() def action_apply_page(): batch = get_current_batch() @@ -170,26 +169,21 @@ def action_apply_page(): load_images() def action_apply_global(): - ui.notify("Starting Global Apply... This may take a moment.") - # Run in background to keep UI responsive + ui.notify("Starting Global Apply...") run.io_bound(SorterEngine.commit_global, state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir) - # We reload after a short delay or assume user will click reload load_images() ui.notify("Global Apply Complete!", type='positive') # ========================================== -# 4. UI COMPONENTS +# 4. UI RENDERERS # ========================================== def open_zoom_dialog(path, title=None): - """Shows full screen image modal""" 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') ui.button(icon='close', on_click=dialog.close).props('flat round dense color=white') - - # Full Res Image ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain max-h-[85vh]') dialog.open() @@ -198,35 +192,28 @@ def render_sidebar(): with sidebar_container: ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') - # --- FEATURE 4: SIDEBAR PREVIEW GRID --- + # Grid Preview 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' - # Closure to capture 'i' and 'is_used' def click_grid(num=i, used=is_used): state.next_index = num - if used: - # Feature 4: Open Preview - open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") - render_sidebar() # Update Next # input + if used: open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") + render_sidebar() ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800') - # --- CATEGORY SELECTOR --- + # Category Selector categories = SorterEngine.get_categories() or ["Default"] if state.active_cat not in categories: state.active_cat = categories[0] - def change_cat(e): - state.active_cat = e.value - refresh_staged_info() - render_sidebar() - - ui.select(categories, value=state.active_cat, label="Active Category", on_change=change_cat) \ + ui.select(categories, value=state.active_cat, label="Active Category", + on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ .classes('w-full').props('dark outlined') - # --- ADD CATEGORY --- + # Add / Delete with ui.row().classes('w-full items-center no-wrap mt-2'): new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('flex-grow') def add_it(): @@ -236,25 +223,20 @@ def render_sidebar(): refresh_staged_info(); render_sidebar() ui.button(icon='add', on_click=add_it).props('flat color=green') - # --- DELETE CATEGORY --- with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'): ui.button('DELETE CAT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), refresh_staged_info(), render_sidebar())).classes('w-full') ui.separator().classes('my-4 bg-gray-700') - # --- INDEX COUNTER --- + # 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 auto_detect(): - used = state.index_map.keys() - state.next_index = max(used) + 1 if used else 1 - ui.button('πŸ”„', on_click=lambda: (auto_detect(), render_sidebar())).props('flat color=white') + ui.button('πŸ”„', on_click=lambda: (setattr(state, 'next_index', (max(state.index_map.keys())+1 if state.index_map else 1)), render_sidebar())).props('flat color=white') def render_gallery(): grid_container.clear() batch = get_current_batch() - # Dynamic thumbnail sizing - thumb_size = int(1600 / state.grid_cols) + thumb_size = int(1800 / state.grid_cols) with grid_container: with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): @@ -269,24 +251,20 @@ def render_gallery(): 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') - # Image - ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded').props('no-spinner') + # Image (Pass Quality Param) + ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}").classes('w-full h-48 object-cover rounded').props('no-spinner') # Actions if is_staged: info = state.staged_data[img_path] - # Extract number for "Untag (#5)" try: num = info['name'].rsplit('_', 1)[1].split('.')[0] label = f"Untag (#{num})" - except: - label = "Untag" - + except: label = "Untag" ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full') ui.button(label, 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 index input 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') @@ -298,21 +276,18 @@ def render_pagination(): if total_pages <= 1: return with pagination_container: - # --- 1. SLIDER (Restored) --- + # Slider def on_slide(e): state.page = int(e.value) refresh_ui() - ui.slider(min=0, max=total_pages-1, value=state.page, on_change=on_slide).classes('w-1/2 mb-2').props('color=green') - # --- 2. CAROUSEL BUTTONS --- + # Buttons with ui.row().classes('items-center gap-2'): ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x > 0) - # Show window of 5 pages start = max(0, state.page - 2) end = min(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" @@ -340,66 +315,63 @@ def handle_key(e): # 5. MAIN LAYOUT # ========================================== -# 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') - # --- FEATURE 5: PROFILE MANAGER --- # Profile Select 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') - # Paths (Flex Grow) + # 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') - # Save Profile Button - ui.button(icon='save', on_click=state.save_current_profile).props('flat round color=white').tooltip('Save Paths to Profile') + 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') - # Load Button - ui.button('LOAD FILES', on_click=load_images).props('color=green flat').classes('font-bold border border-green-700') - - # Dark Toggle + # VIEW SETTINGS MENU (New) + 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:') + def set_cols(v): state.grid_cols=v; refresh_ui() + ui.slider(min=2, max=8, step=1, value=state.grid_cols, on_change=lambda e: set_cols(e.value)).props('color=green') + + ui.label('Preview Quality:') + def set_qual(v): state.preview_quality=v; refresh_ui() + ui.slider(min=10, max=100, step=10, value=state.preview_quality, on_change=lambda e: set_qual(e.value)).props('color=green label-always') + ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green') -# SIDEBAR with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'): sidebar_container = ui.column().classes('w-full') -# CONTENT with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'): pagination_container = ui.column().classes('w-full items-center mb-4') grid_container = ui.column().classes('w-full') - # --- FOOTER: BATCH SETTINGS & GLOBAL APPLY --- + # Footer 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'): - # Settings 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') - 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') - - # Feature 3: Global Apply 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') -# STARTUP ui.keyboard(on_key=handle_key) ui.dark_mode().enable() load_images() From 0d5f393affecf85989765ef64a10aea4181ece67 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 20:56:33 +0100 Subject: [PATCH 14/22] Update gallery_app.py --- gallery_app.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 91c39d1..5a1afca 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -236,6 +236,7 @@ def render_sidebar(): def render_gallery(): grid_container.clear() batch = get_current_batch() + # Dynamic thumbnail sizing thumb_size = int(1800 / state.grid_cols) with grid_container: @@ -243,6 +244,7 @@ def render_gallery(): for img_path in batch: is_staged = img_path in state.staged_data + # Card Container with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): # Header with ui.row().classes('w-full justify-between no-wrap mb-1'): @@ -251,8 +253,12 @@ def render_gallery(): 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') - # Image (Pass Quality Param) - ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}").classes('w-full h-48 object-cover rounded').props('no-spinner') + # --- FIXED IMAGE RENDERING --- + # Changed 'object-cover' to 'object-contain' + # Added 'bg-black' so the empty space around non-square images looks clean + ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \ + .classes('w-full h-48 object-contain bg-black rounded') \ + .props('no-spinner') # Actions if is_staged: From 246b78719eb439b17a1a2db3478a0b428b38af78 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 21:00:55 +0100 Subject: [PATCH 15/22] Update gallery_app.py --- gallery_app.py | 76 +++++++++++++++----------------------------------- 1 file changed, 23 insertions(+), 53 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 5a1afca..7a838e2 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -24,7 +24,7 @@ class AppState: self.page = 0 self.page_size = 24 self.grid_cols = 4 - self.preview_quality = 50 # Default compression level + self.preview_quality = 50 # Tagging State self.active_cat = "Default" @@ -38,7 +38,7 @@ class AppState: self.all_images = [] self.staged_data = {} self.green_dots = set() - self.index_map = {} # {number: path_to_image} + self.index_map = {} def load_active_profile(self): p_data = self.profiles.get(self.profile_name, {}) @@ -56,15 +56,12 @@ class AppState: state = AppState() # ========================================== -# 2. IMAGE SERVING API (Dynamic Quality) +# 2. IMAGE SERVING API # ========================================== @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400, q: int = 50): - """ - Serves WebP thumbnail. - 'q' parameter allows dynamic quality adjustment from the UI. - """ + """Serves 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) @@ -72,7 +69,6 @@ async def get_thumbnail(path: str, size: int = 400, q: int = 50): @app.get('/full_res') async def get_full_res(path: str): if not os.path.exists(path): return Response(status_code=404) - # Full res always uses high quality (90) img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None) return Response(content=img_bytes, media_type="image/webp") @@ -83,10 +79,8 @@ async def get_full_res(path: str): def load_images(): if os.path.exists(state.source_dir): state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) - # Safety check for page bounds total_pages = math.ceil(len(state.all_images) / state.page_size) if state.page >= total_pages: state.page = 0 - refresh_staged_info() refresh_ui() else: @@ -104,16 +98,14 @@ def refresh_staged_info(): # 2. Update Sidebar Index Map state.index_map.clear() - - # Check Staging + # Staging for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: try: num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) state.index_map[num] = orig_path except: pass - - # Check Disk + # Disk cat_path = os.path.join(state.output_dir, state.active_cat) if os.path.exists(cat_path): for f in os.listdir(cat_path): @@ -136,7 +128,6 @@ def action_tag(img_path, manual_idx=None): ext = os.path.splitext(img_path)[1] name = f"{state.active_cat}_{idx:03d}{ext}" - # Conflict Check 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} @@ -145,17 +136,13 @@ def action_tag(img_path, manual_idx=None): name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}" SorterEngine.stage_image(img_path, state.active_cat, name) - if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 - - refresh_staged_info() - refresh_ui() + refresh_staged_info(); refresh_ui() def action_untag(img_path): SorterEngine.clear_staged_item(img_path) - refresh_staged_info() - refresh_ui() + refresh_staged_info(); refresh_ui() def action_delete(img_path): SorterEngine.delete_to_trash(img_path) @@ -197,15 +184,13 @@ def render_sidebar(): for i in range(1, 26): is_used = i in state.index_map color = 'green' if is_used else 'grey-9' - def click_grid(num=i, used=is_used): state.next_index = num if used: open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") render_sidebar() - ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800') - # Category Selector + # Category Select categories = SorterEngine.get_categories() or ["Default"] if state.active_cat not in categories: state.active_cat = categories[0] @@ -236,7 +221,6 @@ def render_sidebar(): def render_gallery(): grid_container.clear() batch = get_current_batch() - # Dynamic thumbnail sizing thumb_size = int(1800 / state.grid_cols) with grid_container: @@ -244,7 +228,6 @@ def render_gallery(): for img_path in batch: is_staged = img_path in state.staged_data - # Card Container with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): # Header with ui.row().classes('w-full justify-between no-wrap mb-1'): @@ -254,21 +237,20 @@ def render_gallery(): ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense color=red') # --- FIXED IMAGE RENDERING --- - # Changed 'object-cover' to 'object-contain' - # Added 'bg-black' so the empty space around non-square images looks clean + # 1. Increased height to h-64 (256px) for better visibility + # 2. Changed object-cover to object-contain (NO CROPPING) + # 3. Added bg-black to fill empty space nicely ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \ - .classes('w-full h-48 object-contain bg-black rounded') \ + .classes('w-full h-64 object-contain bg-black rounded') \ .props('no-spinner') # Actions if is_staged: info = state.staged_data[img_path] - try: - num = info['name'].rsplit('_', 1)[1].split('.')[0] - label = f"Untag (#{num})" - except: label = "Untag" + try: num = info['name'].rsplit('_', 1)[1].split('.')[0] + except: num = "?" ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full') - ui.button(label, on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5 dense').classes('w-full') + ui.button(f"Untag (#{num})", 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') @@ -283,10 +265,7 @@ def render_pagination(): with pagination_container: # Slider - def on_slide(e): - state.page = int(e.value) - refresh_ui() - ui.slider(min=0, max=total_pages-1, value=state.page, on_change=on_slide).classes('w-1/2 mb-2').props('color=green') + ui.slider(min=0, max=total_pages-1, value=state.page, on_change=lambda e: set_page(int(e.value))).classes('w-1/2 mb-2').props('color=green') # Buttons with ui.row().classes('items-center gap-2'): @@ -302,13 +281,10 @@ def render_pagination(): ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x < total_pages - 1) def set_page(p): - state.page = p - refresh_ui() + state.page = p; refresh_ui() def refresh_ui(): - render_sidebar() - render_pagination() - render_gallery() + render_sidebar(); render_pagination(); render_gallery() def handle_key(e): if not e.action.keydown: return @@ -328,9 +304,7 @@ with ui.header().classes('items-center bg-slate-900 text-white border-b border-g # Profile Select profile_names = list(state.profiles.keys()) def change_profile(e): - state.profile_name = e.value - state.load_active_profile() - load_images() + 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') # Paths @@ -341,18 +315,14 @@ with ui.header().classes('items-center bg-slate-900 text-white border-b border-g 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 (New) + # View 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:') - def set_cols(v): state.grid_cols=v; refresh_ui() - ui.slider(min=2, max=8, step=1, value=state.grid_cols, on_change=lambda e: set_cols(e.value)).props('color=green') - + 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:') - def set_qual(v): state.preview_quality=v; refresh_ui() - ui.slider(min=10, max=100, step=10, value=state.preview_quality, on_change=lambda e: set_qual(e.value)).props('color=green label-always') + 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') From 8fc8372a9bd2bc35a8c1fa71d140c0d500bd8798 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 22:29:48 +0100 Subject: [PATCH 16/22] Update gallery_app.py --- gallery_app.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 7a838e2..d06f089 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -221,7 +221,8 @@ def render_sidebar(): def render_gallery(): grid_container.clear() batch = get_current_batch() - thumb_size = int(1800 / state.grid_cols) + # High resolution for grid to allow scaling + thumb_size = 600 with grid_container: with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): @@ -237,11 +238,11 @@ def render_gallery(): ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense color=red') # --- FIXED IMAGE RENDERING --- - # 1. Increased height to h-64 (256px) for better visibility - # 2. Changed object-cover to object-contain (NO CROPPING) - # 3. Added bg-black to fill empty space nicely + # aspect-[4/3]: Height scales with width. + # object-contain: Shows FULL image (no crop), adds black bars if needed. ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \ - .classes('w-full h-64 object-contain bg-black rounded') \ + .classes('w-full aspect-[4/3] object-contain bg-black rounded') \ + .style('aspect-ratio: 4/3') \ .props('no-spinner') # Actions From b919c522559c04f84d3fea517349ffc2b67730a0 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 22:35:08 +0100 Subject: [PATCH 17/22] Update gallery_app.py --- gallery_app.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index d06f089..1dede93 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -221,8 +221,7 @@ def render_sidebar(): def render_gallery(): grid_container.clear() batch = get_current_batch() - # High resolution for grid to allow scaling - thumb_size = 600 + thumb_size = 800 # High res for crisp scaling with grid_container: with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): @@ -237,13 +236,12 @@ def render_gallery(): 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') - # --- FIXED IMAGE RENDERING --- - # aspect-[4/3]: Height scales with width. - # object-contain: Shows FULL image (no crop), adds black bars if needed. + # --- IMAGE RENDERING FIX --- + # 1. w-full h-64: Forces the black box to be exactly 256px tall and full column width. + # 2. fit=contain: PROPS are key here. This tells Quasar/HTML to fit the whole image INSIDE that box. ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \ - .classes('w-full aspect-[4/3] object-contain bg-black rounded') \ - .style('aspect-ratio: 4/3') \ - .props('no-spinner') + .classes('w-full h-64 bg-black rounded') \ + .props('fit=contain no-spinner') # Actions if is_staged: From 0e6de4ae0b12fe0018198081899ae3797deecc09 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 22:55:10 +0100 Subject: [PATCH 18/22] Update gallery_app.py --- gallery_app.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 1dede93..188fe8c 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -155,9 +155,11 @@ def action_apply_page(): ui.notify(f"Page Processed ({state.batch_mode})", type='positive') load_images() -def action_apply_global(): - ui.notify("Starting Global Apply...") - run.io_bound(SorterEngine.commit_global, state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir) +# --- FIX 2: ASYNC GLOBAL APPLY --- +async def action_apply_global(): + ui.notify("Starting Global Apply... This may take a while.") + # Must use 'await' with io_bound to actually wait for completion + 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') @@ -179,16 +181,26 @@ def render_sidebar(): with sidebar_container: ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') - # Grid Preview + # --- FIX 1: SIDEBAR GRID CLICK --- with ui.grid(columns=5).classes('gap-1 mb-4 w-full'): + + # Explicit function to handle the logic + def click_grid(num, used): + state.next_index = num + # Safety check: ensure number exists in map before opening + if used and num in state.index_map: + open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") + render_sidebar() + for i in range(1, 26): is_used = i in state.index_map color = 'green' if is_used else 'grey-9' - def click_grid(num=i, used=is_used): - state.next_index = num - if used: open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") - render_sidebar() - ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800') + + # LAMBDA FIX: Capture 'i' (n) and 'is_used' (u) properly. + # Ignore the first arg 'e' (ClickEvent) + ui.button(str(i), on_click=lambda e, n=i, u=is_used: click_grid(n, u)) \ + .props(f'color={color} size=sm flat') \ + .classes('w-full border border-gray-800') # Category Select categories = SorterEngine.get_categories() or ["Default"] @@ -221,7 +233,7 @@ def render_sidebar(): def render_gallery(): grid_container.clear() batch = get_current_batch() - thumb_size = 800 # High res for crisp scaling + thumb_size = 800 with grid_container: with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): @@ -236,9 +248,7 @@ def render_gallery(): 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') - # --- IMAGE RENDERING FIX --- - # 1. w-full h-64: Forces the black box to be exactly 256px tall and full column width. - # 2. fit=contain: PROPS are key here. This tells Quasar/HTML to fit the whole image INSIDE that box. + # Image 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') From 54ba10d4e56c9742682755c90247c4826a12a39f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 20 Jan 2026 01:27:24 +0100 Subject: [PATCH 19/22] clause sonet --- gallery_app.py | 577 +++++++++++++++++++++++++++++++------------------ 1 file changed, 372 insertions(+), 205 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 188fe8c..2742e96 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -1,15 +1,17 @@ import os import math import asyncio -import json +from typing import Optional, List, Dict, Set from nicegui import ui, app, run from fastapi import Response from engine import SorterEngine # ========================================== -# 1. STATE MANAGEMENT +# STATE MANAGEMENT # ========================================== class AppState: + """Centralized application state with lazy loading.""" + def __init__(self): # Profile Data self.profiles = SorterEngine.load_profiles() @@ -17,7 +19,6 @@ class AppState: if not self.profiles: self.profiles = {"Default": {"tab5_source": "/storage", "tab5_out": "/storage"}} - # Load initial paths self.load_active_profile() # View Settings @@ -35,140 +36,196 @@ class AppState: self.cleanup_mode = "Keep" # Data Caches - self.all_images = [] - self.staged_data = {} - self.green_dots = set() - self.index_map = {} + 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') + 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() # ========================================== -# 2. IMAGE SERVING API +# IMAGE SERVING API # ========================================== @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400, q: int = 50): - """Serves WebP thumbnail with dynamic quality.""" - if not os.path.exists(path): return Response(status_code=404) + """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): - if not os.path.exists(path): return Response(status_code=404) + """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") + return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500) # ========================================== -# 3. CORE LOGIC +# CORE LOGIC # ========================================== def load_images(): - if os.path.exists(state.source_dir): - state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) - total_pages = math.ceil(len(state.all_images) / state.page_size) - if state.page >= total_pages: state.page = 0 - refresh_staged_info() - refresh_ui() - else: + """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() - # 1. Update Green Dots + # 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) - - # 2. Update Sidebar Index Map + + # Build index map for active category state.index_map.clear() - # Staging + + # Add staged images for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: - try: - num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) - state.index_map[num] = orig_path - except: pass - # Disk + 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 f in os.listdir(cat_path): - if f.startswith(state.active_cat) and "_" in f: - try: - num = int(f.rsplit('_', 1)[1].split('.')[0]) - if num not in state.index_map: - state.index_map[num] = os.path.join(cat_path, f) - except: pass + 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 get_current_batch(): - if not state.all_images: return [] - start = state.page * state.page_size - return state.all_images[start : start + state.page_size] +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 --- +# ========================================== +# ACTIONS +# ========================================== -def action_tag(img_path, manual_idx=None): - idx = manual_idx if manual_idx else state.next_index +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) + + # Auto-increment only if using next_index if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 - refresh_staged_info(); refresh_ui() + + refresh_staged_info() + refresh_ui() -def action_untag(img_path): +def action_untag(img_path: str): + """Remove staging from an image.""" SorterEngine.clear_staged_item(img_path) - refresh_staged_info(); refresh_ui() + refresh_staged_info() + refresh_ui() -def action_delete(img_path): +def action_delete(img_path: str): + """Delete image to trash.""" SorterEngine.delete_to_trash(img_path) - load_images() + load_images() def action_apply_page(): - batch = get_current_batch() - if not batch: return + """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') + ui.notify(f"Page processed ({state.batch_mode})", type='positive') load_images() -# --- FIX 2: ASYNC GLOBAL APPLY --- async def action_apply_global(): - ui.notify("Starting Global Apply... This may take a while.") - # Must use 'await' with io_bound to actually wait for completion - await run.io_bound(SorterEngine.commit_global, state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir) + """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.notify("Global apply complete!", type='positive') # ========================================== -# 4. UI RENDERERS +# UI COMPONENTS # ========================================== -def open_zoom_dialog(path, title=None): +def open_zoom_dialog(path: str, title: Optional[str] = None): + """Open full-resolution image dialog.""" 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') @@ -177,187 +234,297 @@ def open_zoom_dialog(path, title=None): dialog.open() def render_sidebar(): - sidebar_container.clear() - with sidebar_container: + """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') - # --- FIX 1: SIDEBAR GRID CLICK --- + # Number grid (1-25) with ui.grid(columns=5).classes('gap-1 mb-4 w-full'): - - # Explicit function to handle the logic - def click_grid(num, used): - state.next_index = num - # Safety check: ensure number exists in map before opening - if used and num in state.index_map: - open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") - render_sidebar() - for i in range(1, 26): is_used = i in state.index_map color = 'green' if is_used else 'grey-9' - # LAMBDA FIX: Capture 'i' (n) and 'is_used' (u) properly. - # Ignore the first arg 'e' (ClickEvent) - ui.button(str(i), on_click=lambda e, n=i, u=is_used: click_grid(n, u)) \ + def make_click_handler(num: int): + def handler(): + state.next_index = num + if num in state.index_map: + open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") + 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 Select - categories = SorterEngine.get_categories() or ["Default"] - if state.active_cat not in categories: state.active_cat = categories[0] - - ui.select(categories, value=state.active_cat, label="Active Category", - on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ - .classes('w-full').props('dark outlined') - - # Add / Delete + # 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...').props('dense outlined dark').classes('flex-grow') - def add_it(): + 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_it).props('flat color=green') - + 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'): - ui.button('DELETE CAT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), refresh_staged_info(), render_sidebar())).classes('w-full') - + 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') - - # Counter + + # 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') - ui.button('πŸ”„', on_click=lambda: (setattr(state, 'next_index', (max(state.index_map.keys())+1 if state.index_map else 1)), render_sidebar())).props('flat color=white') + 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(): - grid_container.clear() - batch = get_current_batch() - thumb_size = 800 + """Render image gallery grid.""" + state.grid_container.clear() + batch = state.get_current_batch() - with grid_container: + with state.grid_container: with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): for img_path in batch: - is_staged = img_path in state.staged_data - - with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): - # Header - 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') + render_image_card(img_path) - # Image - 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') - - # Actions - if is_staged: - info = state.staged_data[img_path] - try: num = info['name'].rsplit('_', 1)[1].split('.')[0] - except: num = "?" - ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full') - ui.button(f"Untag (#{num})", 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_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(): - pagination_container.clear() - if not state.all_images: return + """Render pagination controls.""" + state.pagination_container.clear() - total_pages = math.ceil(len(state.all_images) / state.page_size) - if total_pages <= 1: return - - with pagination_container: - # Slider - ui.slider(min=0, max=total_pages-1, value=state.page, on_change=lambda e: set_page(int(e.value))).classes('w-1/2 mb-2').props('color=green') - - # Buttons + 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'): - ui.button('β—€', on_click=lambda: set_page(state.page - 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x > 0) + # 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(total_pages, state.page + 3) + 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 p=p: set_page(p)).props(f'flat color={color}') - - ui.button('β–Ά', on_click=lambda: set_page(state.page + 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x < total_pages - 1) + 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): - state.page = p; refresh_ui() +def set_page(p: int): + """Navigate to specific page.""" + state.page = max(0, min(p, state.total_pages - 1)) + refresh_ui() def refresh_ui(): - render_sidebar(); render_pagination(); render_gallery() + """Refresh all UI components.""" + render_sidebar() + render_pagination() + render_gallery() -def handle_key(e): - if not e.action.keydown: return - if e.key.arrow_left: set_page(max(0, state.page - 1)) - if e.key.arrow_right: - total = math.ceil(len(state.all_images) / state.page_size) - set_page(min(total - 1, state.page + 1)) - -# ========================================== -# 5. MAIN LAYOUT -# ========================================== - -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 Select - 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') - - # 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 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') - -with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'): - sidebar_container = ui.column().classes('w-full') - -with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'): - pagination_container = ui.column().classes('w-full items-center mb-4') - grid_container = ui.column().classes('w-full') +def handle_keyboard(e): + """Handle keyboard navigation.""" + if not e.action.keydown: + return - # Footer - 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'): - 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') - 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') - 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') + 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) -ui.keyboard(on_key=handle_key) +# ========================================== +# 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="Nice Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file + +ui.run(title="NiceSorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file From 826ae384df8227d281b907c1d67f018304e4d10a Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 20 Jan 2026 01:32:34 +0100 Subject: [PATCH 20/22] Update tab_gallery_sorter.py --- tab_gallery_sorter.py | 909 ++++++++++++++++++++++++++---------------- 1 file changed, 576 insertions(+), 333 deletions(-) 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 From 4c496350182ef09afe9b4c31a7f837c3bdd7b9a0 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 20 Jan 2026 10:47:09 +0100 Subject: [PATCH 21/22] Update gallery_app.py --- gallery_app.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 2742e96..9caf439 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -224,12 +224,39 @@ async def action_apply_global(): # UI COMPONENTS # ========================================== -def open_zoom_dialog(path: str, title: Optional[str] = None): - """Open full-resolution image dialog.""" +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') - ui.button(icon='close', on_click=dialog.close).props('flat round dense color=white') + + 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() From 0c9446b3f8568eb7543d2b0a51b74f78f2b034c7 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 20 Jan 2026 11:27:57 +0100 Subject: [PATCH 22/22] Update gallery_app.py --- gallery_app.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 9caf439..1b74136 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -178,8 +178,8 @@ def action_tag(img_path: str, manual_idx: Optional[int] = None): SorterEngine.stage_image(img_path, state.active_cat, name) - # Auto-increment only if using next_index - if manual_idx is None or manual_idx == state.next_index: + # 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() @@ -275,9 +275,20 @@ def render_sidebar(): def make_click_handler(num: int): def handler(): - state.next_index = num if num in state.index_map: - open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") + # 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)) \