From bad27609dd90a7709b3906470346847a3fcb8a07 Mon Sep 17 00:00:00 2001 From: DhakadG Date: Mon, 10 Aug 2026 17:50:09 +0530 Subject: [PATCH 1/4] v2.0: working engine, rebuilt UI Rewrite of MacAlpha v0.1. Same idea, but the features it advertised now actually run. Fixed (all were live in v0.1): - Drag & drop was never wired up; tkinterdnd2 was in requirements but never imported. The drop zone was a clickable rectangle. - "Preserve EXIF & color profiles" was ignored. piexif was imported and unused; metadata was dropped from every output file. - MAX_THREADS: 8 did nothing. run_batch_conversion() existed but was never called; the progress screen converted serially in a for loop. - Time remaining was permanently "Calculating..."; start_time was assigned and never read. Results always showed "Time: --". - Cancel broke the loop and then reported "Conversion Complete!". - Errors were recorded and never displayed anywhere. - One Label per file meant a frozen window on large batches. - setup.py hardcoded assets/icon.icns, which was never committed, so every py2app build failed. - The CI workflow cd'd into ConvertImagesToWebP-MacAlpha/, a folder not in this repo, so it had never once produced a bundle. - Return started a conversion while you were typing in a settings field (focus_get() returns the inner tkinter.Entry, not CTkEntry). - The log used font family "monospace", which Tk does not define; it silently resolved to Arial, so columns never aligned. New: - Output formats WebP / AVIF / JPEG / PNG, filtered to what the installed Pillow can actually encode. - Presets: Web, Balanced, Archive, Smallest. - Resize by longest edge, width, height or megapixels. Never upscales. - Destination control (subfolder / chosen folder / in place) and an existing-file policy (skip / overwrite / rename). - Preflight count and total size before the run starts. - Live ETA and images/sec, working cancel that reports partial results. - Optional GPS-only metadata stripping. - System / Light / Dark, remembered between launches. - Keyboard shortcuts. Structure: core/ has no UI imports, so the engine is scriptable and testable without a display. gui/theme.py holds design tokens as (light, dark) pairs instead of greys hardcoded across fourteen call sites. Behaviour worth knowing: - The output folder is excluded from scans, so converting a folder twice no longer re-converts its own results. - A failed write is deleted rather than left truncated. - With metadata off, pixels are converted to sRGB so untagged output does not shift color. Tests: tests/test_engine.py (12 checks, no display) and tests/test_gui_boot.py (builds the real window, runs a real conversion through the Tk event loop). CI runs the engine on Linux, the GUI on macOS and Windows, and builds Intel and Apple Silicon bundles separately -- py2app bundles the interpreter it runs with, so a single runner produces an app that will not launch on the other architecture. Verified on Windows 11 / Python 3.12 / Tk 8.6 / CustomTkinter 6.0. Not yet verified on macOS; no Mac was available. CI covers the build. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 80 +++++ .github/workflows/build_mac_app.yml | 43 --- .gitignore | 36 +-- README.md | 185 +++++++---- core/__init__.py | 2 +- core/config.py | 342 ++++++++++++-------- core/converter.py | 345 -------------------- core/imaging.py | 239 ++++++++++++++ core/runner.py | 306 ++++++++++++++++++ gui/__init__.py | 3 +- gui/app.py | 283 +++++++++------- gui/components/__init__.py | 1 - gui/panel.py | 401 +++++++++++++++++++++++ gui/screens/__init__.py | 2 +- gui/screens/dropzone.py | 255 --------------- gui/screens/home.py | 288 +++++++++++++++++ gui/screens/progress.py | 476 +++++++++------------------ gui/screens/results.py | 484 ++++++++++------------------ gui/screens/settings.py | 314 ------------------ gui/theme.py | 74 +++++ gui/widgets.py | 245 ++++++++++++++ main.py | 105 ++++-- requirements.txt | 25 +- setup.py | 109 +++---- tests/__init__.py | 1 + tests/test_engine.py | 226 +++++++++++++ tests/test_gui_boot.py | 110 +++++++ 27 files changed, 2909 insertions(+), 2071 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/build_mac_app.yml delete mode 100644 core/converter.py create mode 100644 core/imaging.py create mode 100644 core/runner.py delete mode 100644 gui/components/__init__.py create mode 100644 gui/panel.py delete mode 100644 gui/screens/dropzone.py create mode 100644 gui/screens/home.py delete mode 100644 gui/screens/settings.py create mode 100644 gui/theme.py create mode 100644 gui/widgets.py create mode 100644 tests/__init__.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_gui_boot.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a3a6dd1 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,80 @@ +name: Build + +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + # The engine has no UI imports, so it tests without a display. + - run: pip install Pillow piexif + - run: python tests/test_engine.py + + smoke: + needs: test + strategy: + fail-fast: false + matrix: + os: [macos-14, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + # Proves the GUI actually constructs on this OS β€” imports, fonts, theme + # tokens, widget options β€” rather than only that the engine passes. + - run: python main.py --check + - run: python tests/test_gui_boot.py + + macos-app: + needs: smoke + strategy: + fail-fast: false + matrix: + include: + # macos-13 is Intel, macos-14 is Apple Silicon. py2app bundles the + # running interpreter, so a single runner produces a single-arch app + # that simply will not launch on the other kind of Mac. + - runner: macos-13 + arch: intel + - runner: macos-14 + arch: apple-silicon + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: | + pip install -r requirements.txt + pip install py2app + # v1's workflow cd'd into a folder that was never in the repo, so this + # job had never once produced a bundle. + - run: python setup.py py2app + - name: Verify the bundle launches + run: | + APP="dist/WebP Studio.app" + test -d "$APP" || { echo "no bundle produced"; exit 1; } + file "$APP/Contents/MacOS/WebP Studio" + # Headless runners have no window server, so a full launch can't be + # tested here β€” confirm the embedded interpreter starts and imports. + "$APP/Contents/MacOS/WebP Studio" --check + - run: ditto -c -k --keepParent "dist/WebP Studio.app" "WebP-Studio-macOS-${{ matrix.arch }}.zip" + - uses: actions/upload-artifact@v4 + with: + name: WebP-Studio-macOS-${{ matrix.arch }} + path: WebP-Studio-macOS-${{ matrix.arch }}.zip + retention-days: 14 diff --git a/.github/workflows/build_mac_app.yml b/.github/workflows/build_mac_app.yml deleted file mode 100644 index 53b96ba..0000000 --- a/.github/workflows/build_mac_app.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Build macOS App - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - workflow_dispatch: # Allow manual trigger - -jobs: - build: - runs-on: macos-latest - - steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - cache: "pip" - - - name: Install Dependencies - run: | - pip install -r ConvertImagesToWebP-MacAlpha/requirements.txt - pip install py2app - - - name: Build .app Bundle - run: | - cd ConvertImagesToWebP-MacAlpha - python setup.py py2app - - - name: Compress App - run: | - cd ConvertImagesToWebP-MacAlpha/dist - zip -r ConvertImagesToWebP-MacAlpha-v0.1.0.zip ConvertImagesToWebP.app - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: MacOS-App-Bundle - path: ConvertImagesToWebP-MacAlpha/dist/ConvertImagesToWebP-MacAlpha-v0.1.0.zip - retention-days: 5 diff --git a/.gitignore b/.gitignore index 8d9278d..975d788 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,8 @@ -# Python __pycache__/ *.py[cod] -*$py.class -*.so -.Python build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual Environment venv/ -env/ -ENV/ -.env - -# PyInstaller/py2app -*.spec -.app/ -build/ -dist/ - -# MacOS +.venv/ .DS_Store - -# IDEs -.vscode/ -.idea/ diff --git a/README.md b/README.md index f667e0c..f329140 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,163 @@ -# ConvertImagesToWebP - MacAlpha v0.1 +# WebP Studio 2.0 -A native macOS GUI application for converting images to WebP format. +Batch image converter for macOS, Windows and Linux. Drop a folder, pick a +preset, get smaller images. -## Features +A rewrite of ConvertImagesToWebP-MacAlpha v0.1 β€” same idea, working engine. -- πŸ–±οΈ **Drag & Drop** - Drop images or folders directly onto the app -- βš™οΈ **Customizable Settings** - Quality, resolution, encoding speed -- πŸ“Š **Real-time Progress** - Watch your conversions in progress -- πŸ“ˆ **Size Savings Stats** - See how much space you saved -- πŸŒ™ **Dark Mode Support** - Native macOS appearance +--- -## Supported Formats +## What's new in 2.0 + +**Things v0.1 advertised but didn't do** + +| | v0.1 | 2.0 | +|---|---|---| +| Drag & drop | `tkinterdnd2` in requirements, never imported | works; the app tells you if the package is missing | +| "Preserve EXIF & color profiles" toggle | ignored β€” metadata dropped from every file | actually written, plus optional GPS-only removal | +| `MAX_THREADS: 8` | unused; conversion ran one file at a time | real thread pool, auto-sized to your CPU | +| Time remaining | permanently "Calculating…" | live ETA and images/sec | +| Cancel | stopped, then reported "Conversion Complete!" | reports what finished and what never started | +| Errors | recorded, never displayed | listed on screen and savable to a log | +| 5,000-image batches | one widget per file, frozen window | single capped log view | +| `python setup.py py2app` | failed β€” missing `assets/icon.icns` | builds without an icon | +| GitHub Actions build | `cd` into a folder that isn't in the repo | fixed | + +**New** + +- **Output formats** β€” WebP, AVIF, JPEG, PNG (only the ones your Pillow build can write are offered) +- **Presets** β€” Web Β· Balanced Β· Archive Β· Smallest +- **Resize by** longest edge, width, height, or megapixels. Never upscales. +- **Destination control** β€” subfolder, a folder you choose, or next to each original +- **If a file already exists** β€” skip, overwrite, or rename +- **Preflight** β€” "482 images Β· 3.1 GB β†’ Pictures/Converted" before you commit +- **Lossless mode**, encoder-effort control, worker count +- **System / Light / Dark**, remembered between launches +- **Keyboard** β€” `Ctrl/⌘O` folder Β· `Ctrl/βŒ˜β‡§O` files Β· `Return` convert Β· `Esc` stop or clear -- JPEG (.jpg, .jpeg) -- PNG (.png) -- BMP (.bmp) -- TIFF (.tiff, .tif) -- HEIC (.heic) -- WebP (.webp) - direct copy +--- -## Installation +## Install -### 1. Prerequisite: Homebrew +```bash +pip install -r requirements.txt +python main.py +``` -If you don't have Homebrew installed, open Terminal and run: +Only `customtkinter` and `Pillow` are required. The rest are optional and the +app degrades cleanly without them β€” check what you have: ```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +python main.py --check ``` -### 2. Setup Python & Dependencies +| Optional | Enables | +|---|---| +| `tkinterdnd2` | drag & drop onto the window | +| `piexif` | removing GPS tags while keeping the rest of the EXIF | +| `pillow-heif` | reading iPhone `.heic` / `.heif` | + +## macOS -Run these commands one by one in Terminal: +**Use Homebrew's Python, not Apple's.** macOS ships Tk 8.5.9; CustomTkinter +needs 8.6+ and renders as black rectangles below that. `python main.py --check` +prints your Tk version and says so if it's too old. ```bash -# Install Python and Tkinter via Homebrew brew install python python-tk +/opt/homebrew/bin/python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt +python main.py +``` -# Navigate to the app folder -cd ConvertImagesToWebP-MacAlpha +### Build a .app -# Create a virtual environment (fixes 'pip' issues) -python3 -m venv venv +```bash +python setup.py py2app # -> dist/WebP Studio.app +``` -# Activate the virtual environment -source venv/bin/activate +Drop an `assets/icon.icns` in first if you want a custom icon β€” unlike v1, the +build no longer fails without one. -# Install required libraries -pip install -r requirements.txt +**py2app bundles the interpreter it is run with, so the result is single-arch.** +An app built on an M-series Mac will not launch on an Intel Mac and vice versa. +CI therefore builds both (`macos-13` Intel, `macos-14` Apple Silicon) and +uploads them as separate artifacts. To produce one universal binary instead, +build with a universal2 python.org interpreter rather than a Homebrew one. + +`LSMinimumSystemVersion` is set to 10.13, but the real floor is whatever the +building Python supports. + +### "The app is damaged and can't be opened" + +That is Gatekeeper, not a broken build β€” the bundle is unsigned and +un-notarized, and anything downloaded from a browser or CI artifact gets +quarantined. Either right-click β†’ Open the first time, or: + +```bash +xattr -dr com.apple.quarantine "/Applications/WebP Studio.app" ``` -### 3. Run the App +Signing and notarizing requires a paid Apple Developer account; that is the +only real fix for distributing to other people. + +## Tests ```bash -python main.py +python tests/test_engine.py # no display needed +python tests/test_gui_boot.py # needs a display ``` -### Build .app Bundle (via GitHub Actions) +No framework. `test_engine` covers sizing math, alpha flattening, metadata +keep/strip, output-collision handling, the skip/overwrite/rename policies, +error isolation, cancel, and savings accounting. `test_gui_boot` builds the +real window and drives a real conversion through the Tk event loop β€” it exists +to catch what only breaks on a specific OS (fonts that don't resolve, widget +options a platform's Tk rejects) and runs in CI on macOS and Windows. -**Since you are on Windows**, you cannot build the macOS app directly. Instead: +Both write settings to a temp directory via `WEBP_STUDIO_CONFIG_DIR`, so they +never touch your real config. -1. Push this code to a GitHub repository -2. Go to the **Actions** tab in your repo -3. Select **Build macOS App** workflow -4. Run workflow (or it runs on push) -5. Download the `MacOS-App-Bundle` artifact when done +--- -### Build .app Bundle (on macOS) +## How it works -```bash -python setup.py py2app +``` +main.py entry point + dependency check +core/ + config.py Settings dataclass, presets, JSON persistence + imaging.py one image: open β†’ orient β†’ resize β†’ square β†’ encode + runner.py scan, plan destinations, thread pool, cancel, progress +gui/ + theme.py design tokens β€” every color is a (light, dark) pair + widgets.py Card, StatTile, ProgressRing, SliderRow, LogView + panel.py the settings panel + screens/ home Β· progress Β· results ``` -This creates `ConvertImagesToWebP.app` in the `dist/` folder. +`core/` has no UI imports, so the engine is usable from a script and testable +without a display. -## Usage +### Notes on behaviour -1. **Drop Zone** - Drag images/folders or click Browse -2. **Settings** - Adjust quality, resolution, encoding options -3. **Progress** - Watch real-time conversion progress -4. **Results** - View stats and open output folder +- **Resizing never upscales.** A limit larger than the source is a no-op. +- **Metadata off converts to sRGB.** An untagged file is read as sRGB by every + viewer, so baking the profile in keeps colors from shifting. +- **The output folder is excluded from scans.** Converting the same folder + twice will not re-convert its own results. +- **Failed files leave nothing behind.** A partial write is deleted, because a + truncated image looks fine in a file manager and fails later. +- **Animated sources take frame one**, and say so in the log. -## Settings +## Verified on -| Setting | Description | Default | -| ----------- | -------------------------- | -------- | -| Quality | WebP quality (1-100) | 90 | -| Resolution | Max megapixels limit | 19 MP | -| Encoding | Speed vs compression (1-6) | 6 (Best) | -| Metadata | Preserve EXIF/ICC profiles | Yes | -| Square Mode | Original, Crop, or Canvas | Original | +| | Status | +|---|---| +| Windows 11 Β· Python 3.12 Β· Tk 8.6 Β· CustomTkinter 6.0 | both test suites pass; app driven end to end | +| Engine logic (any OS) | 12 checks, no display required | +| macOS | **not yet run** β€” no Mac available to the author. Push to `main` and the CI matrix will build and smoke-test Intel and Apple Silicon bundles. | +| Linux | should work; `test_gui_boot` needs `xvfb` in CI | ## License -MIT License - Feel free to modify and distribute. - ---- - -Made with ❀️ for macOS +MIT. diff --git a/core/__init__.py b/core/__init__.py index 4ef2aba..3f9cfd5 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1 +1 @@ -# Core processing engine +"""Conversion engine: settings, single-image pipeline, batch runner.""" diff --git a/core/config.py b/core/config.py index c5f520e..9e2d710 100644 --- a/core/config.py +++ b/core/config.py @@ -1,163 +1,225 @@ -""" -Configuration management for ConvertImagesToWebP - MacAlpha v0.1 +"""Settings: dataclass + JSON persistence + presets. -Handles all user settings with JSON persistence and defaults. -Cross-platform compatible (macOS primary, Windows/Linux secondary). +One flat dataclass. No nesting, no schema layer β€” it is a settings file, not a +database. """ +from __future__ import annotations + import json -from pathlib import Path -from dataclasses import dataclass, fields, asdict -from typing import Tuple, Dict, Any, List +import os import platform +from dataclasses import dataclass, field, fields +from pathlib import Path +from typing import Any +APP_NAME = "ConvertImagesToWebP" +VERSION = "2.0.0" -# Determine config directory based on OS -def get_config_dir() -> Path: - """Get the appropriate config directory for the current OS.""" - if platform.system() == "Darwin": # macOS - config_dir = Path.home() / "Library" / "Application Support" / "ConvertImagesToWebP" - elif platform.system() == "Windows": - config_dir = Path.home() / "AppData" / "Local" / "ConvertImagesToWebP" - else: # Linux and others - config_dir = Path.home() / ".config" / "ConvertImagesToWebP" - - config_dir.mkdir(parents=True, exist_ok=True) - return config_dir - - -CONFIG_DIR = get_config_dir() -CONFIG_FILE = CONFIG_DIR / "settings.json" -PRESETS_DIR = CONFIG_DIR / "presets" - - -@dataclass -class AppConfig: - """ - Application configuration settings. - - All settings are stored here for easy management and JSON serialization. - """ - - # Output settings - OUTPUT_FOLDER: str = "WebP_Converted" - - # Supported file extensions - EXTENSIONS: Tuple[str, ...] = ( - ".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp", ".ico", ".heic" - ) - - # Compression settings - QUALITY: int = 90 # WebP quality (1-100) - TARGET_MEGAPIXELS: float = 19.0 # Max resolution limit - KEEP_METADATA: bool = True # Preserve EXIF/ICC profiles - WEBP_METHOD: int = 6 # Encoding method (1=fast, 6=best) - - # System settings - MAX_THREADS: int = 8 # Max parallel threads - - # Square mode settings (mutually exclusive) - ENABLE_CROP_SQUARE: bool = False # Center-crop to 1:1 - ENABLE_SQUARE_CANVAS: bool = False # Fit into 1:1 canvas - CANVAS_FILL_MODE: str = "transparent" # 'transparent' or 'color' - CANVAS_FILL_COLOR: Tuple[int, int, int] = (255, 255, 255) # RGB - - # UI preferences - THEME: str = "dark" # 'dark' or 'light' - WINDOW_WIDTH: int = 600 - WINDOW_HEIGHT: int = 700 - - # Recent folders (for quick access) - RECENT_FOLDERS: List[str] = None - - def __post_init__(self): - """Initialize default values for mutable fields.""" - if self.RECENT_FOLDERS is None: - self.RECENT_FOLDERS = [] +# Everything we will try to read. Availability of HEIC/AVIF decoding is probed +# at runtime in formats.py β€” this is just the filename filter. +SOURCE_EXTENSIONS: tuple[str, ...] = ( + ".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", + ".webp", ".avif", ".heic", ".heif", ".gif", ".ico", +) - def validate(self) -> List[str]: - """Validate configuration values.""" - errors = [] +OUTPUT_FORMATS = ("webp", "avif", "jpeg", "png") +RESIZE_MODES = ("none", "long_edge", "width", "height", "megapixels") +SQUARE_MODES = ("off", "crop", "canvas") +DEST_MODES = ("subfolder", "custom", "in_place") +ON_EXISTING = ("skip", "overwrite", "rename") +THEMES = ("system", "light", "dark") - if not 1 <= self.QUALITY <= 100: - errors.append(f"QUALITY must be 1-100, got {self.QUALITY}") - if not 0.1 <= self.TARGET_MEGAPIXELS <= 500.0: - errors.append(f"TARGET_MEGAPIXELS must be 0.1-500, got {self.TARGET_MEGAPIXELS}") +def config_dir() -> Path: + # Override lets tests run without clobbering the real user's settings. + override = os.environ.get("WEBP_STUDIO_CONFIG_DIR") + if override: + path = Path(override) + path.mkdir(parents=True, exist_ok=True) + return path - if not 1 <= self.WEBP_METHOD <= 6: - errors.append(f"WEBP_METHOD must be 1-6, got {self.WEBP_METHOD}") + system = platform.system() + if system == "Darwin": + base = Path.home() / "Library" / "Application Support" / APP_NAME + elif system == "Windows": + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / APP_NAME + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME + base.mkdir(parents=True, exist_ok=True) + return base - if self.CANVAS_FILL_MODE not in ("transparent", "color"): - errors.append(f"CANVAS_FILL_MODE must be 'transparent' or 'color'") - if self.ENABLE_CROP_SQUARE and self.ENABLE_SQUARE_CANVAS: - errors.append("ENABLE_CROP_SQUARE and ENABLE_SQUARE_CANVAS cannot both be True") +CONFIG_FILE = config_dir() / "settings.json" - return errors - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary for JSON serialization.""" - data = {} - for field_info in fields(self): - value = getattr(self, field_info.name) - if isinstance(value, tuple): - data[field_info.name] = list(value) - else: - data[field_info.name] = value - return data +@dataclass +class Settings: + # --- output --------------------------------------------------------- + # Defaults deliberately equal the "Balanced" preset, so a fresh install + # opens on a named preset instead of "Custom". + output_format: str = "webp" + quality: int = 85 + lossless: bool = False + effort: int = 6 # webp `method` / avif `speed`, 0-6, higher = slower+smaller + + # --- geometry ------------------------------------------------------- + resize_mode: str = "none" + resize_value: float = 2560.0 # px for long_edge/width/height, MP for megapixels + square_mode: str = "off" + canvas_fill: str = "transparent" # "transparent" or "#rrggbb" + + # --- metadata ------------------------------------------------------- + keep_metadata: bool = True + strip_gps: bool = False + + # --- destination ---------------------------------------------------- + dest_mode: str = "subfolder" + dest_folder: str = "" # used when dest_mode == "custom" + subfolder_name: str = "Converted" + on_existing: str = "skip" + + # --- runtime -------------------------------------------------------- + threads: int = 0 # 0 = auto (cpu_count) + + # --- ui ------------------------------------------------------------- + theme: str = "system" + window_width: int = 940 + window_height: int = 720 + recent_folders: list[str] = field(default_factory=list) + + # ------------------------------------------------------------------ + def worker_count(self) -> int: + if self.threads > 0: + return self.threads + return max(1, min(16, (os.cpu_count() or 4))) + + def output_suffix(self) -> str: + return {"jpeg": ".jpg"}.get(self.output_format, "." + self.output_format) + + def clamp(self) -> None: + """Coerce every field back into a legal range. Called after load and + before every run, so a hand-edited settings.json can't crash a batch.""" + self.output_format = _one_of(self.output_format, OUTPUT_FORMATS, "webp") + self.resize_mode = _one_of(self.resize_mode, RESIZE_MODES, "none") + self.square_mode = _one_of(self.square_mode, SQUARE_MODES, "off") + self.dest_mode = _one_of(self.dest_mode, DEST_MODES, "subfolder") + self.on_existing = _one_of(self.on_existing, ON_EXISTING, "skip") + self.theme = _one_of(self.theme, THEMES, "system") + + self.quality = _clamp_int(self.quality, 1, 100, 82) + self.effort = _clamp_int(self.effort, 0, 6, 6) + self.threads = _clamp_int(self.threads, 0, 64, 0) + self.window_width = _clamp_int(self.window_width, 720, 4000, 940) + self.window_height = _clamp_int(self.window_height, 560, 3000, 720) + + lo, hi = (0.1, 500.0) if self.resize_mode == "megapixels" else (16.0, 30000.0) + self.resize_value = _clamp_float(self.resize_value, lo, hi, 2560.0) + + if not (self.canvas_fill == "transparent" or _is_hex_color(self.canvas_fill)): + self.canvas_fill = "transparent" + if not isinstance(self.recent_folders, list): + self.recent_folders = [] + self.recent_folders = [str(p) for p in self.recent_folders][:10] + if not str(self.subfolder_name).strip(): + self.subfolder_name = "Converted" + + # ------------------------------------------------------------------ + def to_dict(self) -> dict[str, Any]: + return {f.name: getattr(self, f.name) for f in fields(self)} @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "AppConfig": - """Create config from dictionary.""" - valid_fields = {f.name for f in fields(cls)} - filtered = {k: v for k, v in data.items() if k in valid_fields} - - # Convert lists back to tuples - if "EXTENSIONS" in filtered and isinstance(filtered["EXTENSIONS"], list): - filtered["EXTENSIONS"] = tuple(filtered["EXTENSIONS"]) - if "CANVAS_FILL_COLOR" in filtered and isinstance(filtered["CANVAS_FILL_COLOR"], list): - filtered["CANVAS_FILL_COLOR"] = tuple(filtered["CANVAS_FILL_COLOR"]) - - return cls(**filtered) + def from_dict(cls, data: dict[str, Any]) -> "Settings": + known = {f.name for f in fields(cls)} + obj = cls(**{k: v for k, v in data.items() if k in known}) + obj.clamp() + return obj def save(self) -> None: - """Save configuration to file.""" - CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(self.to_dict(), f, indent=2) + # Write-then-rename: a crash mid-write can't leave a truncated config. + tmp = CONFIG_FILE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8") + tmp.replace(CONFIG_FILE) @classmethod - def load(cls) -> "AppConfig": - """Load configuration from file, or return defaults.""" - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - config = cls.from_dict(data) - errors = config.validate() - if not errors: - return config - except (json.JSONDecodeError, Exception): - pass - return cls() + def load(cls) -> "Settings": + try: + return cls.from_dict(json.loads(CONFIG_FILE.read_text(encoding="utf-8"))) + except Exception: + return cls() def add_recent_folder(self, folder: Path) -> None: - """Add a folder to recent folders list.""" - folder_str = str(folder.resolve()) - - # Remove if already exists - if folder_str in self.RECENT_FOLDERS: - self.RECENT_FOLDERS.remove(folder_str) - - # Add to front - self.RECENT_FOLDERS.insert(0, folder_str) - - # Keep only last 10 - self.RECENT_FOLDERS = self.RECENT_FOLDERS[:10] - - self.save() - - -# Global config instance -config = AppConfig.load() + s = str(Path(folder).resolve()) + if s in self.recent_folders: + self.recent_folders.remove(s) + self.recent_folders.insert(0, s) + self.recent_folders = self.recent_folders[:10] + + def apply_preset(self, name: str) -> None: + for key, value in PRESETS.get(name, {}).items(): + setattr(self, key, value) + self.clamp() + + def matching_preset(self) -> str: + for name, values in PRESETS.items(): + if all(getattr(self, k) == v for k, v in values.items()): + return name + return "Custom" + + +# Presets only touch encode/geometry knobs β€” never destination or threads, +# so switching a preset can't silently redirect where files land. +PRESETS: dict[str, dict[str, Any]] = { + "Web": { + "output_format": "webp", "quality": 80, "lossless": False, "effort": 6, + "resize_mode": "long_edge", "resize_value": 2048.0, "keep_metadata": False, + }, + "Balanced": { + "output_format": "webp", "quality": 85, "lossless": False, "effort": 6, + "resize_mode": "none", "resize_value": 2560.0, "keep_metadata": True, + }, + "Archive": { + "output_format": "webp", "quality": 95, "lossless": False, "effort": 6, + "resize_mode": "none", "resize_value": 2560.0, "keep_metadata": True, + }, + "Smallest": { + "output_format": "avif", "quality": 55, "lossless": False, "effort": 6, + "resize_mode": "long_edge", "resize_value": 1600.0, "keep_metadata": False, + }, +} + +PRESET_HINTS = { + "Web": "2048 px Β· q80 WebP Β· metadata stripped", + "Balanced": "Full size Β· q85 WebP Β· metadata kept", + "Archive": "Full size Β· q95 WebP Β· metadata kept", + "Smallest": "1600 px Β· q55 AVIF Β· metadata stripped", + "Custom": "Your own settings", +} + + +# --------------------------------------------------------------------------- +def _one_of(value: Any, allowed: tuple[str, ...], fallback: str) -> str: + return value if value in allowed else fallback + + +def _clamp_int(value: Any, lo: int, hi: int, fallback: int) -> int: + try: + return max(lo, min(hi, int(value))) + except (TypeError, ValueError): + return fallback + + +def _clamp_float(value: Any, lo: float, hi: float, fallback: float) -> float: + try: + return max(lo, min(hi, float(value))) + except (TypeError, ValueError): + return fallback + + +def _is_hex_color(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == 7 + and value.startswith("#") + and all(c in "0123456789abcdefABCDEF" for c in value[1:]) + ) diff --git a/core/converter.py b/core/converter.py deleted file mode 100644 index 03ae7a4..0000000 --- a/core/converter.py +++ /dev/null @@ -1,345 +0,0 @@ -""" -Core Converter Module - WebP conversion engine. - -This module wraps the v6 processing logic for use in the GUI app. -It provides a simplified interface for image conversion with progress callbacks. -""" - -import os -import sys -import shutil -from pathlib import Path -from typing import List, Tuple, Dict, Any, Optional, Callable -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass -from io import BytesIO -import threading - -# Image processing imports -try: - from PIL import Image, ImageOps, ImageCms - import piexif -except ImportError: - print("Required packages not installed. Run: pip install Pillow piexif") - sys.exit(1) - -# Import config -sys.path.insert(0, str(Path(__file__).parent.parent)) -from core.config import AppConfig - -# Allow processing very large images -Image.MAX_IMAGE_PIXELS = None - - -@dataclass -class ConversionResult: - """Result from converting a single image.""" - success: bool - file_path: Path - output_path: Optional[Path] - error_message: Optional[str] - original_size: int - output_size: int - was_skipped: bool = False - was_copied: bool = False - - -def find_images(source_paths: List[Path], extensions: Tuple[str, ...]) -> List[Path]: - """ - Find all image files from the given source paths. - - Args: - source_paths: List of files and/or folders to search - extensions: Tuple of valid file extensions (lowercase, with dot) - - Returns: - List of image file paths - """ - images = [] - extensions_lower = tuple(ext.lower() for ext in extensions) - - for source in source_paths: - if source.is_file(): - if source.suffix.lower() in extensions_lower: - images.append(source) - elif source.is_dir(): - for ext in extensions: - images.extend(source.rglob(f"*{ext}")) - images.extend(source.rglob(f"*{ext.upper()}")) - - # Remove duplicates and sort - images = sorted(set(images)) - return images - - -def get_safe_output_name( - file_path: Path, - output_folder: Path, - root_folder: Path, - extensions: Tuple[str, ...] -) -> Path: - """ - Generate output path with smart collision detection. - - Only adds original extension suffix when there's an actual naming conflict. - """ - try: - rel_parent = file_path.relative_to(root_folder).parent - except ValueError: - rel_parent = Path() - - base_name = file_path.stem - original_ext = file_path.suffix.lower() - simple_name = f"{base_name}.webp" - ext_suffix = original_ext[1:] if original_ext.startswith(".") else original_ext - collision_name = f"{base_name}_{ext_suffix}.webp" - - output_dir = output_folder / rel_parent - source_dir = file_path.parent - - try: - conflicting_files = [] - for f in source_dir.iterdir(): - if not f.is_file(): - continue - if f.stem.lower() != base_name.lower(): - continue - if f.suffix.lower() == original_ext: - continue - if f.suffix.lower() in tuple(ext.lower() for ext in extensions): - conflicting_files.append(f) - - if conflicting_files: - return output_dir / collision_name - else: - return output_dir / simple_name - except OSError: - return output_dir / simple_name - - -def convert_to_srgb(img: Image.Image) -> Image.Image: - """Convert image from wide-gamut to sRGB colorspace.""" - if img.mode not in ("RGB", "RGBA"): - return img - - icc_profile = img.info.get("icc_profile") - if not icc_profile: - return img - - try: - srgb_profile = ImageCms.createProfile("sRGB") - source_profile = ImageCms.ImageCmsProfile(BytesIO(icc_profile)) - - if img.mode == "RGBA": - transform = ImageCms.buildTransformFromOpenProfiles( - source_profile, srgb_profile, "RGBA", "RGBA" - ) - else: - transform = ImageCms.buildTransformFromOpenProfiles( - source_profile, srgb_profile, "RGB", "RGB" - ) - - img = ImageCms.applyTransform(img, transform) - img.info["icc_profile"] = ImageCms.ImageCmsProfile(srgb_profile).tobytes() - except Exception: - pass - - return img - - -def process_single_image( - file_path: Path, - output_folder: Path, - config: AppConfig, - root_folder: Optional[Path] = None -) -> ConversionResult: - """ - Process a single image: resize, convert to WebP. - - Args: - file_path: Path to source image - output_folder: Destination folder for WebP output - config: Configuration instance - root_folder: Root folder for relative path calculation - - Returns: - ConversionResult with success status and details - """ - if root_folder is None: - root_folder = file_path.parent - - original_size = 0 - output_size = 0 - - try: - # Get original size - original_size = file_path.stat().st_size - - # Calculate output path - destination = get_safe_output_name(file_path, output_folder, root_folder, config.EXTENSIONS) - destination.parent.mkdir(parents=True, exist_ok=True) - - # Skip check - if destination exists and is newer - if destination.exists(): - if destination.stat().st_mtime > file_path.stat().st_mtime: - output_size = destination.stat().st_size - return ConversionResult( - success=True, - file_path=file_path, - output_path=destination, - error_message=None, - original_size=original_size, - output_size=output_size, - was_skipped=True - ) - - # WebP direct copy - if file_path.suffix.lower() == ".webp": - shutil.copy2(file_path, destination) - output_size = original_size - return ConversionResult( - success=True, - file_path=file_path, - output_path=destination, - error_message=None, - original_size=original_size, - output_size=output_size, - was_copied=True - ) - - # Open and process image - with Image.open(file_path) as img: - # Handle EXIF orientation - img = ImageOps.exif_transpose(img) - - # Convert to sRGB - img = convert_to_srgb(img) - - # Calculate current megapixels - width, height = img.size - current_mp = (width * height) / 1_000_000 - - # Resize if needed - if current_mp > config.TARGET_MEGAPIXELS: - scale_factor = (config.TARGET_MEGAPIXELS / current_mp) ** 0.5 - new_width = int(width * scale_factor) - new_height = int(height * scale_factor) - img = img.resize((new_width, new_height), Image.LANCZOS) - - # Handle square modes - if config.ENABLE_CROP_SQUARE: - # Center crop to 1:1 - width, height = img.size - size = min(width, height) - left = (width - size) // 2 - top = (height - size) // 2 - img = img.crop((left, top, left + size, top + size)) - - elif config.ENABLE_SQUARE_CANVAS: - # Fit into square canvas - width, height = img.size - size = max(width, height) - - if config.CANVAS_FILL_MODE == "transparent": - canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - else: - canvas = Image.new("RGB", (size, size), config.CANVAS_FILL_COLOR) - - x = (size - width) // 2 - y = (size - height) // 2 - canvas.paste(img, (x, y)) - img = canvas - - # Prepare for WebP save - save_kwargs = { - "quality": config.QUALITY, - "method": config.WEBP_METHOD, - } - - # Handle transparency - if img.mode == "RGBA": - save_kwargs["lossless"] = False - elif img.mode != "RGB": - img = img.convert("RGB") - - # Save as WebP - img.save(destination, "WebP", **save_kwargs) - - output_size = destination.stat().st_size - - return ConversionResult( - success=True, - file_path=file_path, - output_path=destination, - error_message=None, - original_size=original_size, - output_size=output_size - ) - - except Exception as e: - return ConversionResult( - success=False, - file_path=file_path, - output_path=None, - error_message=str(e), - original_size=original_size, - output_size=0 - ) - - -def run_batch_conversion( - images: List[Path], - output_folder: Path, - root_folder: Path, - config: AppConfig, - progress_callback: Optional[Callable[[int, int, str], None]] = None, - max_threads: int = 8 -) -> List[ConversionResult]: - """ - Convert a batch of images with parallel processing. - - Args: - images: List of image paths to convert - output_folder: Destination folder - root_folder: Root folder for relative paths - config: Configuration instance - progress_callback: Optional callback(completed, total, current_file) - max_threads: Maximum parallel threads - - Returns: - List of ConversionResult objects - """ - results = [] - total = len(images) - completed = 0 - results_lock = threading.Lock() - - with ThreadPoolExecutor(max_workers=max_threads) as executor: - future_to_path = { - executor.submit(process_single_image, img, output_folder, config, root_folder): img - for img in images - } - - for future in as_completed(future_to_path): - img_path = future_to_path[future] - - try: - result = future.result() - except Exception as e: - result = ConversionResult( - success=False, - file_path=img_path, - output_path=None, - error_message=str(e), - original_size=0, - output_size=0 - ) - - with results_lock: - results.append(result) - completed += 1 - - if progress_callback: - progress_callback(completed, total, img_path.name) - - return results diff --git a/core/imaging.py b/core/imaging.py new file mode 100644 index 0000000..09c2ced --- /dev/null +++ b/core/imaging.py @@ -0,0 +1,239 @@ +"""Single-image pipeline: open β†’ orient β†’ resize β†’ square β†’ encode. + +Everything here is pure and thread-safe: no shared state, no UI, no disk +scanning. `convert_file` is the only entry point the runner needs. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +from PIL import Image, ImageCms, ImageOps, features + +from core.config import Settings + +# We routinely handle 100 MP camera scans; the decompression-bomb guard is for +# untrusted input, and these files come from the user's own disk. +Image.MAX_IMAGE_PIXELS = None + +# Optional decoders. Both are pure-import side effects, so probe once at module +# load rather than per-file. +try: # HEIC/HEIF from iPhones + import pillow_heif # type: ignore + + pillow_heif.register_heif_opener() + HEIF_OK = True +except Exception: + HEIF_OK = False + +try: + import piexif # type: ignore + + PIEXIF_OK = True +except Exception: + PIEXIF_OK = False + + +def available_output_formats() -> list[str]: + """Formats this Pillow build can actually write. Offering a format the + install can't encode is how you get a 500-file batch that fails on file 1.""" + out = ["jpeg", "png"] + if features.check("webp"): + out.insert(0, "webp") + try: + if features.check("avif"): + out.insert(1 if "webp" in out else 0, "avif") + except Exception: + pass + return out + + +def readable_extensions(all_extensions: tuple[str, ...]) -> tuple[str, ...]: + """Drop HEIC/HEIF from the scan filter when no HEIF decoder is installed.""" + if HEIF_OK: + return all_extensions + return tuple(e for e in all_extensions if e not in (".heic", ".heif")) + + +@dataclass +class Encoded: + width: int + height: int + note: str = "" + + +# --------------------------------------------------------------------------- +# Metadata +# --------------------------------------------------------------------------- +def _prepare_exif(raw: bytes | None, strip_gps: bool) -> bytes | None: + if not raw: + return None + if not strip_gps: + return raw + if not PIEXIF_OK: + # Can't surgically remove GPS, so drop the whole block rather than + # silently shipping the coordinates the user asked to remove. + return None + try: + data = piexif.load(raw) + data["GPS"] = {} + return piexif.dump(data) + except Exception: + return None + + +def _to_srgb(img: Image.Image, icc: bytes | None) -> Image.Image: + """Bake a wide-gamut profile into sRGB pixels. + + Only used when metadata is being dropped: an untagged file is interpreted + as sRGB by every viewer, so without this the colors shift visibly. + """ + if not icc or img.mode not in ("RGB", "RGBA"): + return img + try: + src = ImageCms.ImageCmsProfile(BytesIO(icc)) + dst = ImageCms.createProfile("sRGB") + return ImageCms.profileToProfile(img, src, dst, outputMode=img.mode) or img + except Exception: + return img # bad/exotic profile: better untouched than crashed + + +# --------------------------------------------------------------------------- +# Geometry +# --------------------------------------------------------------------------- +def _target_size(width: int, height: int, settings: Settings) -> tuple[int, int]: + """Return the new size, downscale-only. Upscaling never adds detail, it + just makes the file bigger, so a limit above the source is a no-op.""" + mode, value = settings.resize_mode, settings.resize_value + if mode == "none" or width <= 0 or height <= 0: + return width, height + + if mode == "megapixels": + current_mp = (width * height) / 1_000_000 + if current_mp <= value: + return width, height + scale = (value / current_mp) ** 0.5 + elif mode == "long_edge": + longest = max(width, height) + if longest <= value: + return width, height + scale = value / longest + elif mode == "width": + if width <= value: + return width, height + scale = value / width + elif mode == "height": + if height <= value: + return width, height + scale = value / height + else: + return width, height + + return max(1, round(width * scale)), max(1, round(height * scale)) + + +def _apply_square(img: Image.Image, settings: Settings) -> Image.Image: + if settings.square_mode == "crop": + w, h = img.size + side = min(w, h) + left, top = (w - side) // 2, (h - side) // 2 + return img.crop((left, top, left + side, top + side)) + + if settings.square_mode == "canvas": + w, h = img.size + side = max(w, h) + if settings.canvas_fill == "transparent": + canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0)) + if img.mode != "RGBA": + img = img.convert("RGBA") + else: + canvas = Image.new("RGB", (side, side), settings.canvas_fill) + img = _flatten(img, settings.canvas_fill) + canvas.paste(img, ((side - w) // 2, (side - h) // 2)) + return canvas + + return img + + +def _flatten(img: Image.Image, background: str) -> Image.Image: + """Composite alpha onto a solid color β€” required for JPEG, which has none.""" + if img.mode not in ("RGBA", "LA", "PA") and "transparency" not in img.info: + return img.convert("RGB") + img = img.convert("RGBA") + color = background if background != "transparent" else "#ffffff" + base = Image.new("RGBA", img.size, color) + return Image.alpha_composite(base, img).convert("RGB") + + +def _normalize_mode(img: Image.Image, output_format: str, settings: Settings) -> Image.Image: + has_alpha = img.mode in ("RGBA", "LA", "PA") or "transparency" in img.info + if output_format == "jpeg": + return _flatten(img, settings.canvas_fill) if has_alpha else img.convert("RGB") + if has_alpha: + return img if img.mode == "RGBA" else img.convert("RGBA") + return img if img.mode == "RGB" else img.convert("RGB") + + +# --------------------------------------------------------------------------- +# Encode +# --------------------------------------------------------------------------- +def _save_kwargs(settings: Settings, exif: bytes | None, icc: bytes | None) -> dict: + fmt = settings.output_format + kwargs: dict = {} + + if fmt == "webp": + kwargs.update(quality=settings.quality, method=settings.effort) + if settings.lossless: + # `exact` keeps RGB values under fully-transparent pixels, which + # lossless mode is otherwise free to discard. + kwargs.update(lossless=True, exact=True) + elif fmt == "avif": + # Pillow's AVIF `speed` is inverted vs WebP's `method`: 0 is slowest. + kwargs.update(quality=settings.quality, speed=max(0, 6 - settings.effort)) + if settings.lossless: + kwargs["quality"] = 100 + elif fmt == "jpeg": + kwargs.update(quality=settings.quality, optimize=True, progressive=True, + subsampling="4:4:4" if settings.quality >= 90 else "4:2:0") + elif fmt == "png": + kwargs.update(optimize=True, compress_level=min(9, settings.effort + 3)) + + if exif: + kwargs["exif"] = exif + if icc: + kwargs["icc_profile"] = icc + return kwargs + + +PIL_FORMAT = {"webp": "WEBP", "avif": "AVIF", "jpeg": "JPEG", "png": "PNG"} + + +def convert_file(source: Path, destination: Path, settings: Settings) -> Encoded: + """Convert one image. Raises on failure β€” the runner turns that into a + per-file error row so one bad file can't abort the batch.""" + note = "" + with Image.open(source) as opened: + if getattr(opened, "n_frames", 1) > 1: + note = "animated source, first frame only" + + img = ImageOps.exif_transpose(opened) or opened # honor camera rotation + icc = img.info.get("icc_profile") + exif = _prepare_exif(img.info.get("exif"), settings.strip_gps) if settings.keep_metadata else None + + if not settings.keep_metadata: + img = _to_srgb(img, icc) + icc = None + + new_size = _target_size(*img.size, settings) + if new_size != img.size: + img = img.resize(new_size, Image.LANCZOS) + + img = _apply_square(img, settings) + img = _normalize_mode(img, settings.output_format, settings) + + destination.parent.mkdir(parents=True, exist_ok=True) + img.save(destination, PIL_FORMAT[settings.output_format], + **_save_kwargs(settings, exif, icc)) + return Encoded(img.width, img.height, note) diff --git a/core/runner.py b/core/runner.py new file mode 100644 index 0000000..bcaee78 --- /dev/null +++ b/core/runner.py @@ -0,0 +1,306 @@ +"""Batch orchestration: scan sources, plan destinations, run a thread pool. + +UI-agnostic on purpose β€” it takes plain callbacks, so the GUI can marshal them +onto the Tk thread however it likes, and the tests can call it directly. +""" + +from __future__ import annotations + +import os +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Iterable + +from core.config import Settings +from core.imaging import convert_file + +CONVERTED, SKIPPED, FAILED, CANCELLED = "converted", "skipped", "failed", "cancelled" + + +@dataclass +class FileResult: + source: Path + status: str + destination: Path | None = None + source_bytes: int = 0 + output_bytes: int = 0 + message: str = "" + + @property + def saved_bytes(self) -> int: + return self.source_bytes - self.output_bytes if self.status == CONVERTED else 0 + + +@dataclass +class Scan: + files: list[Path] = field(default_factory=list) + root: Path = Path(".") + total_bytes: int = 0 + + def __bool__(self) -> bool: + return bool(self.files) + + +@dataclass +class Progress: + completed: int + total: int + converted: int + skipped: int + failed: int + bytes_in: int + bytes_out: int + elapsed: float + current: str = "" + + @property + def fraction(self) -> float: + return self.completed / self.total if self.total else 0.0 + + @property + def rate(self) -> float: + """Files per second so far.""" + return self.completed / self.elapsed if self.elapsed > 0.5 else 0.0 + + @property + def eta_seconds(self) -> float | None: + if self.completed < 3 or self.rate <= 0: + return None # too early to be anything but a lie + return (self.total - self.completed) / self.rate + + +# --------------------------------------------------------------------------- +# Scanning +# --------------------------------------------------------------------------- +def common_root(paths: Iterable[Path]) -> Path: + """Deepest directory containing every source, so relative structure is + preserved instead of everything being flattened into one folder.""" + dirs = [p if p.is_dir() else p.parent for p in paths] + if not dirs: + return Path.cwd() + try: + return Path(os.path.commonpath([str(d.resolve()) for d in dirs])) + except ValueError: # different drives on Windows + return dirs[0].resolve() + + +def scan_sources(sources: list[Path], extensions: tuple[str, ...], + exclude_under: Path | None = None) -> Scan: + """Collect every readable image below `sources`. + + `exclude_under` keeps a previous run's output folder out of the next run β€” + without it, converting a folder twice re-converts its own results. + """ + wanted = {e.lower() for e in extensions} + found: set[Path] = set() + skip_root = exclude_under.resolve() if exclude_under else None + + def keep(path: Path) -> bool: + if path.suffix.lower() not in wanted: + return False + if skip_root: + try: + path.resolve().relative_to(skip_root) + return False + except ValueError: + pass + return True + + for source in sources: + if source.is_file(): + if keep(source): + found.add(source.resolve()) + elif source.is_dir(): + # One walk, filter by suffix β€” rglob per extension re-walks the + # tree N times and double-counts on case-insensitive filesystems. + for path in source.rglob("*"): + if path.is_file() and keep(path): + found.add(path.resolve()) + + files = sorted(found) + total = 0 + for f in files: + try: + total += f.stat().st_size + except OSError: + pass + return Scan(files=files, root=common_root(sources), total_bytes=total) + + +def destination_root(sources: list[Path], settings: Settings) -> Path | None: + """Where output lands. None means in-place (next to each source).""" + if settings.dest_mode == "in_place": + return None + if settings.dest_mode == "custom" and settings.dest_folder.strip(): + return Path(settings.dest_folder).expanduser() + return common_root(sources) / settings.subfolder_name + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- +class Runner: + """Runs one batch. Single use β€” build a new one per run.""" + + def __init__(self, scan: Scan, settings: Settings, + on_progress: Callable[[Progress], None] | None = None, + on_file: Callable[[FileResult], None] | None = None, + on_finish: Callable[[list[FileResult], bool, float], None] | None = None): + self.scan = scan + self.settings = settings + self.on_progress = on_progress + self.on_file = on_file + self.on_finish = on_finish + + self.results: list[FileResult] = [] + self._cancel = threading.Event() + self._lock = threading.Lock() + self._claimed: set[str] = set() + self._started = 0.0 + self._counts = {CONVERTED: 0, SKIPPED: 0, FAILED: 0} + self._bytes_in = 0 + self._bytes_out = 0 + + # -- public --------------------------------------------------------- + def cancel(self) -> None: + self._cancel.set() + + @property + def cancelled(self) -> bool: + return self._cancel.is_set() + + def run(self) -> list[FileResult]: + """Blocking. Call from a worker thread if you have a UI.""" + self.settings.clamp() + self._started = time.monotonic() + dest_root = destination_root([self.scan.root], self.settings) + + workers = self.settings.worker_count() + with ThreadPoolExecutor(max_workers=workers) as pool: + # map() streams lazily enough that cancel takes effect quickly, + # and each worker reports its own completion under the lock. + for _ in pool.map(lambda f: self._process(f, dest_root), self.scan.files): + pass + + elapsed = time.monotonic() - self._started + if self.on_finish: + self.on_finish(self.results, self.cancelled, elapsed) + return self.results + + def start_background(self) -> threading.Thread: + thread = threading.Thread(target=self.run, name="convert-batch", daemon=True) + thread.start() + return thread + + # -- internals ------------------------------------------------------ + def _process(self, source: Path, dest_root: Path | None) -> None: + if self._cancel.is_set(): + self._record(FileResult(source, CANCELLED)) + return + + try: + source_bytes = source.stat().st_size + except OSError as exc: + self._record(FileResult(source, FAILED, message=str(exc))) + return + + try: + destination = self._claim_destination(source, dest_root) + except _Skip: + self._record(FileResult(source, SKIPPED, source_bytes=source_bytes, + message="output already exists")) + return + + try: + encoded = convert_file(source, destination, self.settings) + output_bytes = destination.stat().st_size + self._record(FileResult(source, CONVERTED, destination, source_bytes, + output_bytes, encoded.note)) + except Exception as exc: + # Half-written output is worse than none β€” a truncated file looks + # valid to a file manager and fails silently later. + destination.unlink(missing_ok=True) + self._record(FileResult(source, FAILED, source_bytes=source_bytes, + message=f"{type(exc).__name__}: {exc}")) + + def _claim_destination(self, source: Path, dest_root: Path | None) -> Path: + """Reserve a unique output path. Raises _Skip under the skip policy.""" + if dest_root is None: + folder = source.parent + else: + try: + relative = source.parent.relative_to(self.scan.root) + except ValueError: + relative = Path() + folder = dest_root / relative + + suffix = self.settings.output_suffix() + base = folder / (source.stem + suffix) + policy = self.settings.on_existing + + with self._lock: + candidate, index = base, 0 + while True: + key = str(candidate).lower() + taken_this_run = key in self._claimed + exists_on_disk = candidate.exists() + + if not taken_this_run and not exists_on_disk: + self._claimed.add(key) + return candidate + if not taken_this_run and exists_on_disk and policy == "overwrite": + self._claimed.add(key) + return candidate + if not taken_this_run and exists_on_disk and policy == "skip": + raise _Skip + # rename policy, or two sources competing for one name this run + index += 1 + candidate = folder / f"{source.stem}_{index}{suffix}" + + def _record(self, result: FileResult) -> None: + with self._lock: + self.results.append(result) + if result.status in self._counts: + self._counts[result.status] += 1 + self._bytes_in += result.source_bytes + self._bytes_out += result.output_bytes + snapshot = Progress( + completed=len(self.results), + total=len(self.scan.files), + converted=self._counts[CONVERTED], + skipped=self._counts[SKIPPED], + failed=self._counts[FAILED], + bytes_in=self._bytes_in, + bytes_out=self._bytes_out, + elapsed=time.monotonic() - self._started, + current=result.source.name, + ) + if self.on_file: + self.on_file(result) + if self.on_progress: + self.on_progress(snapshot) + + +class _Skip(Exception): + """Destination exists and the policy says leave it alone.""" + + +# --------------------------------------------------------------------------- +def format_bytes(size: float) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size) < 1024 or unit == "TB": + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + + +def format_duration(seconds: float) -> str: + seconds = max(0, int(seconds)) + if seconds < 60: + return f"{seconds}s" + if seconds < 3600: + return f"{seconds // 60}m {seconds % 60:02d}s" + return f"{seconds // 3600}h {(seconds % 3600) // 60:02d}m" diff --git a/gui/__init__.py b/gui/__init__.py index 89bc6e0..9b16634 100644 --- a/gui/__init__.py +++ b/gui/__init__.py @@ -1,2 +1 @@ -# ConvertImagesToWebP - MacAlpha v0.1 -# macOS GUI App for WebP Conversion +"""GUI layer: theme tokens, widgets, screens.""" diff --git a/gui/app.py b/gui/app.py index e65c172..670d945 100644 --- a/gui/app.py +++ b/gui/app.py @@ -1,153 +1,192 @@ -""" -Main Application Window for ConvertImagesToWebP - MacAlpha v0.1 +"""Application shell: window, header, navigation, drag & drop, shortcuts.""" -This is the main entry point for the GUI app. It manages: -- Window creation and theming -- Screen navigation (Drop Zone β†’ Settings β†’ Progress β†’ Results) -- Global state management -""" +from __future__ import annotations -import customtkinter as ctk -from pathlib import Path -from typing import Optional, List, Callable +import platform import sys -import os +import tkinter +from pathlib import Path -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) +import customtkinter as ctk -from core.config import config, AppConfig -from gui.screens.dropzone import DropZoneScreen -from gui.screens.settings import SettingsScreen +from core.config import VERSION, Settings +from core.runner import FileResult, Scan +from gui import theme as t +from gui.screens.home import HomeScreen from gui.screens.progress import ProgressScreen from gui.screens.results import ResultsScreen +from gui.widgets import relayout_ring_colors, segmented +try: + from tkinterdnd2 import DND_FILES, TkinterDnD -class WebPConverterApp(ctk.CTk): - """ - Main application window for ConvertImagesToWebP MacAlpha. + DND_IMPORTED = True +except Exception: # optional: the app is fully usable without it + DND_IMPORTED = False - Manages screen navigation and global state. - """ +IS_MAC = platform.system() == "Darwin" +MOD = "Command" if IS_MAC else "Control" - VERSION = "0.1.0" - APP_NAME = "ConvertImagesToWebP - MacAlpha" - def __init__(self): +class App(ctk.CTk): + def __init__(self) -> None: super().__init__() + self.settings = Settings.load() + self.sources: list[Path] = [] + self.scan: Scan | None = None - # Configure window - self.title(self.APP_NAME) - self.geometry(f"{config.WINDOW_WIDTH}x{config.WINDOW_HEIGHT}") - self.minsize(500, 600) - - # Set theme - ctk.set_appearance_mode(config.THEME) + t.apply_appearance(self.settings.theme) ctk.set_default_color_theme("blue") - # State management - self.source_paths: List[Path] = [] - self.output_folder: Optional[Path] = None - self.processing_results: List = [] + self.title(f"WebP Studio {VERSION}") + self.geometry(f"{self.settings.window_width}x{self.settings.window_height}") + self.minsize(900, 640) + self.configure(fg_color=t.BG) - # Screen container - self.container = ctk.CTkFrame(self, fg_color="transparent") - self.container.pack(fill="both", expand=True, padx=0, pady=0) - self.container.grid_rowconfigure(0, weight=1) - self.container.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) - # Initialize screens (lazy loading pattern) - self.screens = {} - self.current_screen = None + self._build_header() + self._build_screens() + self._enable_drag_and_drop() + self._bind_shortcuts() - # Show initial screen - self.show_screen("dropzone") - - # Bind window close self.protocol("WM_DELETE_WINDOW", self.on_close) + self.show("home") + + # -- chrome --------------------------------------------------------- + def _build_header(self) -> None: + header = ctk.CTkFrame(self, fg_color="transparent", height=64) + header.grid(row=0, column=0, sticky="ew", padx=t.XL, pady=(t.LG, 0)) + header.grid_columnconfigure(1, weight=1) + + titles = ctk.CTkFrame(header, fg_color="transparent") + titles.grid(row=0, column=0, sticky="w") + ctk.CTkLabel(titles, text="WebP Studio", font=t.font(20, "bold"), + text_color=t.TEXT).grid(row=0, column=0, sticky="w") + self.tagline = ctk.CTkLabel(titles, text="Batch image conversion", + font=t.font(11), text_color=t.MUTED) + self.tagline.grid(row=1, column=0, sticky="w") + + self.theme_buttons = segmented(header, ["System", "Light", "Dark"], + self.settings.theme.capitalize(), + self._set_theme) + self.theme_buttons.configure(width=210) + self.theme_buttons.grid(row=0, column=2, sticky="e") + + def _build_screens(self) -> None: + self.container = ctk.CTkFrame(self, fg_color="transparent") + self.container.grid(row=1, column=0, sticky="nsew") + self.container.grid_columnconfigure(0, weight=1) + self.container.grid_rowconfigure(0, weight=1) - def show_screen(self, screen_name: str, **kwargs) -> None: - """ - Navigate to a specific screen. - - Args: - screen_name: One of 'dropzone', 'settings', 'progress', 'results' - **kwargs: Additional arguments to pass to the screen - """ - # Create screen if not exists - if screen_name not in self.screens: - self.screens[screen_name] = self._create_screen(screen_name) - - # Hide current screen - if self.current_screen: - self.current_screen.pack_forget() - - # Show new screen - screen = self.screens[screen_name] - screen.pack(fill="both", expand=True) - self.current_screen = screen - - # Update screen with any passed data - if hasattr(screen, "on_show"): - screen.on_show(**kwargs) - - def _create_screen(self, screen_name: str) -> ctk.CTkFrame: - """Create a screen instance by name.""" - screen_classes = { - "dropzone": DropZoneScreen, - "settings": SettingsScreen, - "progress": ProgressScreen, - "results": ResultsScreen, + self.screens = { + "home": HomeScreen(self.container, self), + "progress": ProgressScreen(self.container, self), + "results": ResultsScreen(self.container, self), } - - screen_class = screen_classes.get(screen_name) - if screen_class: - return screen_class(self.container, app=self) - else: - raise ValueError(f"Unknown screen: {screen_name}") - - def set_source_paths(self, paths: List[Path]) -> None: - """Set the source paths to process.""" - self.source_paths = paths - - # Determine output folder - if paths: - if paths[0].is_dir(): - self.output_folder = paths[0] / config.OUTPUT_FOLDER - else: - self.output_folder = paths[0].parent / config.OUTPUT_FOLDER - - def start_processing(self) -> None: - """Navigate to progress screen and start processing.""" - self.show_screen("progress") - - def show_results(self, results: List) -> None: - """Navigate to results screen with processing results.""" - self.processing_results = results - self.show_screen("results", results=results) - - def reset_and_go_home(self) -> None: - """Reset state and go back to drop zone.""" - self.source_paths = [] - self.output_folder = None - self.processing_results = [] - self.show_screen("dropzone") + self.current = "" + + def show(self, name: str) -> None: + for screen in self.screens.values(): + screen.grid_remove() + screen = self.screens[name] + screen.grid(row=0, column=0, sticky="nsew") + self.current = name + if hasattr(screen, "on_show"): + screen.on_show() + + def _set_theme(self, label: str) -> None: + self.settings.theme = label.lower() + t.apply_appearance(self.settings.theme) + self.settings.save() + # Rings are raw Canvas drawings; CustomTkinter can't repaint them for us. + relayout_ring_colors(self) + + # -- drag & drop ---------------------------------------------------- + def _enable_drag_and_drop(self) -> None: + """Wire tkinterdnd2 if present. v1 shipped a 'drop zone' that could not + accept a drop; without the package we at least say so.""" + self.dnd_enabled = False + if not DND_IMPORTED: + self.tagline.configure( + text="Batch image conversion Β· install tkinterdnd2 for drag & drop") + return + try: + self.TkdndVersion = TkinterDnD._require(self) + self.drop_target_register(DND_FILES) + self.dnd_bind("<>", self._on_drop) + self.dnd_bind("<>", lambda _e: self._highlight(True)) + self.dnd_bind("<>", lambda _e: self._highlight(False)) + self.dnd_enabled = True + except Exception: + self.tagline.configure( + text="Batch image conversion Β· drag & drop unavailable on this build") + + def _on_drop(self, event) -> None: + self._highlight(False) + if self.current != "home": + return # dropping mid-run would silently discard the drop + paths = [Path(p) for p in self.tk.splitlist(event.data)] + self.screens["home"].handle_drop(paths) + + def _highlight(self, active: bool) -> None: + if self.current == "home": + self.screens["home"].highlight_drop(active) + + # -- shortcuts ------------------------------------------------------ + def _bind_shortcuts(self) -> None: + self.bind_all(f"<{MOD}-o>", lambda _e: self._if_home(lambda h: h.browse_folder())) + self.bind_all(f"<{MOD}-Shift-o>", lambda _e: self._if_home(lambda h: h.browse_files())) + self.bind_all("", lambda _e: self._if_home(lambda h: h.start())) + self.bind_all("", lambda _e: self._escape()) + + def _is_typing(self) -> bool: + """CustomTkinter wraps a real tkinter.Entry, and focus_get() returns + that inner widget β€” so checking for "CTkEntry" never matched and Return + started a conversion while you were still typing a value.""" + return isinstance(self.focus_get(), (tkinter.Entry, tkinter.Text)) + + def _if_home(self, action) -> None: + if self.current == "home" and not self._is_typing(): + action(self.screens["home"]) + + def _escape(self) -> None: + if self.current == "progress": + self.screens["progress"].cancel() + elif self.current == "results": + self.go_home() + elif self.current == "home": + self.screens["home"].clear_sources() + + # -- flow ----------------------------------------------------------- + def begin_conversion(self, scan: Scan) -> None: + self.show("progress") + self.screens["progress"].start(scan) + + def finish_conversion(self, results: list[FileResult], cancelled: bool, + elapsed: float) -> None: + self.show("results") + self.screens["results"].show(results, cancelled, elapsed) + + def go_home(self) -> None: + self.show("home") def on_close(self) -> None: - """Handle window close event.""" - # Save window geometry to config - config.WINDOW_WIDTH = self.winfo_width() - config.WINDOW_HEIGHT = self.winfo_height() - config.save() - + progress = self.screens.get("progress") + if progress and progress.runner and not progress.runner.cancelled: + progress.runner.cancel() # don't leave workers writing after the window dies + self.settings.window_width = self.winfo_width() + self.settings.window_height = self.winfo_height() + self.settings.save() self.destroy() -def main(): - """Main entry point.""" - app = WebPConverterApp() +def main() -> int: + app = App() app.mainloop() + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/gui/components/__init__.py b/gui/components/__init__.py deleted file mode 100644 index 706e74d..0000000 --- a/gui/components/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Reusable UI components diff --git a/gui/panel.py b/gui/panel.py new file mode 100644 index 0000000..6e083a1 --- /dev/null +++ b/gui/panel.py @@ -0,0 +1,401 @@ +"""The settings panel that lives beside the drop zone on the home screen. + +v1 made settings a separate wizard step, so you couldn't see what you'd +selected while choosing how to convert it. This is the same options, live, +next to the files. +""" + +from __future__ import annotations + +from pathlib import Path +from tkinter import filedialog +from typing import Callable + +import customtkinter as ctk + +from core.config import PRESET_HINTS, PRESETS, Settings +from core.imaging import available_output_formats +from gui import theme as t +from gui.widgets import (Card, SliderRow, ghost_button, option_menu, section_label, + segmented, switch) + +RESIZE_LABELS = { + "none": "No limit", + "long_edge": "Longest edge", + "width": "Max width", + "height": "Max height", + "megapixels": "Max megapixels", +} +RESIZE_BY_LABEL = {v: k for k, v in RESIZE_LABELS.items()} + +DEST_LABELS = { + "subfolder": "Subfolder beside originals", + "custom": "Choose a folder…", + "in_place": "Next to each original", +} +DEST_BY_LABEL = {v: k for k, v in DEST_LABELS.items()} + +THREAD_LABELS = ["Auto", "1", "2", "4", "8", "16"] + + +class SettingsPanel(ctk.CTkScrollableFrame): + def __init__(self, parent, settings: Settings, on_change: Callable[[], None]): + super().__init__(parent, fg_color="transparent", corner_radius=0) + self.settings = settings + self.on_change = on_change + self.grid_columnconfigure(0, weight=1) + self._row = 0 + self._building = True + + self._build_preset() + self._build_encoding() + self._build_geometry() + self._build_metadata() + self._build_destination() + + self._building = False + self._sync_conditional_rows() + + # -- helpers -------------------------------------------------------- + def _add(self, widget, pady=(0, t.MD)) -> None: + # right padding keeps cards clear of the scrollbar + widget.grid(row=self._row, column=0, sticky="ew", padx=(0, t.SM), pady=pady) + self._row += 1 + + def _changed(self, refresh_preset: bool = True) -> None: + if self._building: + return + self.settings.clamp() + if refresh_preset: + self._refresh_preset_label() + self._sync_conditional_rows() + self.on_change() + + # -- presets -------------------------------------------------------- + def _build_preset(self) -> None: + card = Card(self, "Preset") + body = card.body() + # "Custom" is a state, not a choice β€” showing it as a fifth button both + # crowded the row and implied you could click your way into it. + current = self.settings.matching_preset() + self.preset_buttons = segmented(body, list(PRESETS), + current if current != "Custom" else "", + self._apply_preset) + self.preset_buttons.grid(row=0, column=0, sticky="ew") + self.preset_hint = ctk.CTkLabel(body, text="", font=t.font(11), + text_color=t.MUTED, anchor="w") + self.preset_hint.grid(row=1, column=0, sticky="w", pady=(t.SM, 0)) + self._add(card) + self._refresh_preset_label() + + def _apply_preset(self, name: str) -> None: + if name not in PRESETS: + return + self.settings.apply_preset(name) + self._reload_controls() + self._changed(refresh_preset=True) + + def _refresh_preset_label(self) -> None: + name = self.settings.matching_preset() + self.preset_buttons.set(name if name != "Custom" else "") + self.preset_hint.configure(text=PRESET_HINTS.get(name, "")) + + # -- encoding ------------------------------------------------------- + def _build_encoding(self) -> None: + card = Card(self, "Format & quality") + body = card.body() + + formats = available_output_formats() + if self.settings.output_format not in formats: + self.settings.output_format = formats[0] + self.format_buttons = segmented(body, [f.upper() for f in formats], + self.settings.output_format.upper(), + self._set_format) + self.format_buttons.grid(row=0, column=0, sticky="ew") + + self.quality_row = SliderRow( + body, "Quality", 1, 100, self.settings.quality, self._set_quality, "%", + hint="Lower is smaller. 80–85 is visually lossless for most photos.") + self.quality_row.grid(row=1, column=0, sticky="ew", pady=(t.MD, 0)) + + self.lossless_switch = switch(body, "Lossless (ignores quality)", + self.settings.lossless, self._set_lossless) + self.lossless_switch.grid(row=2, column=0, sticky="w", pady=(t.MD, 0)) + + self.effort_row = SliderRow( + body, "Encoder effort", 0, 6, self.settings.effort, self._set_effort, + hint="Higher squeezes a little more out of each file, and takes longer.") + self.effort_row.grid(row=3, column=0, sticky="ew", pady=(t.MD, 0)) + + threads = ctk.CTkFrame(body, fg_color="transparent") + threads.grid(row=4, column=0, sticky="ew", pady=(t.MD, 0)) + threads.grid_columnconfigure(0, weight=1) + ctk.CTkLabel(threads, text="Parallel workers", font=t.font(12), + text_color=t.TEXT, anchor="w").grid(row=0, column=0, sticky="w") + current = "Auto" if self.settings.threads == 0 else str(self.settings.threads) + self.threads_menu = option_menu(threads, THREAD_LABELS, + current if current in THREAD_LABELS else "Auto", + self._set_threads, width=110) + self.threads_menu.grid(row=0, column=1, sticky="e") + self._add(card) + + def _set_format(self, label: str) -> None: + self.settings.output_format = label.lower() + self._changed() + + def _set_quality(self, value: int) -> None: + self.settings.quality = value + self._changed() + + def _set_lossless(self, value: bool) -> None: + self.settings.lossless = value + self._changed() + + def _set_effort(self, value: int) -> None: + self.settings.effort = value + self._changed() + + def _set_threads(self, label: str) -> None: + self.settings.threads = 0 if label == "Auto" else int(label) + self._changed(refresh_preset=False) + + # -- geometry ------------------------------------------------------- + def _build_geometry(self) -> None: + card = Card(self, "Size & shape") + body = card.body() + + row = ctk.CTkFrame(body, fg_color="transparent") + row.grid(row=0, column=0, sticky="ew") + row.grid_columnconfigure(0, weight=1) + ctk.CTkLabel(row, text="Downscale", font=t.font(12), text_color=t.TEXT, + anchor="w").grid(row=0, column=0, sticky="w") + self.resize_menu = option_menu(row, list(RESIZE_LABELS.values()), + RESIZE_LABELS[self.settings.resize_mode], + self._set_resize_mode, width=170) + self.resize_menu.grid(row=0, column=1, sticky="e") + + self.resize_value_row = ctk.CTkFrame(body, fg_color="transparent") + self.resize_value_row.grid(row=1, column=0, sticky="ew", pady=(t.SM, 0)) + self.resize_value_row.grid_columnconfigure(0, weight=1) + self.resize_value_caption = ctk.CTkLabel(self.resize_value_row, text="", + font=t.font(11), text_color=t.MUTED, + anchor="w") + self.resize_value_caption.grid(row=0, column=0, sticky="w") + self.resize_entry = ctk.CTkEntry(self.resize_value_row, width=100, + font=t.font(12), fg_color=t.SURFACE_ALT, + border_color=t.BORDER, text_color=t.TEXT, + corner_radius=t.RADIUS_SM) + self.resize_entry.grid(row=0, column=1, sticky="e") + self.resize_entry.bind("", lambda _e: self._commit_resize_value()) + self.resize_entry.bind("", lambda _e: self._commit_resize_value()) + + ctk.CTkLabel(body, text="Never upscales β€” a limit above the original is ignored.", + font=t.font(10), text_color=t.FAINT, anchor="w").grid( + row=2, column=0, sticky="w", pady=(t.XS, 0)) + + section_label(body, "Square").grid(row=3, column=0, sticky="w", pady=(t.MD, t.XS)) + self.square_buttons = segmented(body, ["Off", "Crop", "Canvas"], + self.settings.square_mode.capitalize() + if self.settings.square_mode != "off" else "Off", + self._set_square) + self.square_buttons.grid(row=4, column=0, sticky="ew") + + self.fill_row = ctk.CTkFrame(body, fg_color="transparent") + self.fill_row.grid(row=5, column=0, sticky="ew", pady=(t.SM, 0)) + self.fill_row.grid_columnconfigure(0, weight=1) + ctk.CTkLabel(self.fill_row, text="Canvas fill", font=t.font(11), + text_color=t.MUTED, anchor="w").grid(row=0, column=0, sticky="w") + self.fill_entry = ctk.CTkEntry(self.fill_row, width=120, font=t.font(12), + fg_color=t.SURFACE_ALT, border_color=t.BORDER, + text_color=t.TEXT, corner_radius=t.RADIUS_SM, + placeholder_text="transparent or #ffffff") + self.fill_entry.insert(0, self.settings.canvas_fill) + self.fill_entry.grid(row=0, column=1, sticky="e") + self.fill_entry.bind("", lambda _e: self._commit_fill()) + self.fill_entry.bind("", lambda _e: self._commit_fill()) + self._add(card) + + def _set_resize_mode(self, label: str) -> None: + self.settings.resize_mode = RESIZE_BY_LABEL[label] + if self.settings.resize_mode == "megapixels" and self.settings.resize_value > 500: + self.settings.resize_value = 12.0 + elif self.settings.resize_mode != "megapixels" and self.settings.resize_value < 16: + self.settings.resize_value = 2048.0 + self._changed() + self._reload_resize_entry() + + def _commit_resize_value(self) -> None: + try: + self.settings.resize_value = float(self.resize_entry.get().strip()) + except ValueError: + pass # keep the old value; _reload_resize_entry puts it back on screen + self._changed() + self._reload_resize_entry() + + def _reload_resize_entry(self) -> None: + value = self.settings.resize_value + text = f"{value:g}" + self.resize_entry.delete(0, "end") + self.resize_entry.insert(0, text) + unit = "megapixels" if self.settings.resize_mode == "megapixels" else "pixels" + self.resize_value_caption.configure(text=f"Limit ({unit})") + + def _set_square(self, label: str) -> None: + self.settings.square_mode = label.lower() + self._changed() + + def _commit_fill(self) -> None: + self.settings.canvas_fill = self.fill_entry.get().strip() or "transparent" + self._changed() + self.fill_entry.delete(0, "end") + self.fill_entry.insert(0, self.settings.canvas_fill) + + # -- metadata ------------------------------------------------------- + def _build_metadata(self) -> None: + card = Card(self, "Metadata") + body = card.body() + self.metadata_switch = switch(body, "Keep EXIF and color profile", + self.settings.keep_metadata, self._set_metadata) + self.metadata_switch.grid(row=0, column=0, sticky="w") + self.gps_switch = switch(body, "…but remove GPS location", + self.settings.strip_gps, self._set_gps) + self.gps_switch.grid(row=1, column=0, sticky="w", pady=(t.SM, 0)) + ctk.CTkLabel(body, text="With metadata off, colors are converted to sRGB so " + "untagged files still look right.", + font=t.font(10), text_color=t.FAINT, anchor="w", + wraplength=300, justify="left").grid(row=2, column=0, sticky="w", + pady=(t.SM, 0)) + self._add(card) + + def _set_metadata(self, value: bool) -> None: + self.settings.keep_metadata = value + self._changed() + + def _set_gps(self, value: bool) -> None: + self.settings.strip_gps = value + self._changed(refresh_preset=False) + + # -- destination ---------------------------------------------------- + def _build_destination(self) -> None: + card = Card(self, "Where to save") + body = card.body() + + self.dest_menu = option_menu(body, list(DEST_LABELS.values()), + DEST_LABELS[self.settings.dest_mode], + self._set_dest_mode, width=240) + self.dest_menu.grid(row=0, column=0, sticky="ew") + + self.subfolder_row = ctk.CTkFrame(body, fg_color="transparent") + self.subfolder_row.grid(row=1, column=0, sticky="ew", pady=(t.SM, 0)) + self.subfolder_row.grid_columnconfigure(0, weight=1) + ctk.CTkLabel(self.subfolder_row, text="Folder name", font=t.font(11), + text_color=t.MUTED, anchor="w").grid(row=0, column=0, sticky="w") + self.subfolder_entry = ctk.CTkEntry(self.subfolder_row, width=140, + font=t.font(12), fg_color=t.SURFACE_ALT, + border_color=t.BORDER, text_color=t.TEXT, + corner_radius=t.RADIUS_SM) + self.subfolder_entry.insert(0, self.settings.subfolder_name) + self.subfolder_entry.grid(row=0, column=1, sticky="e") + self.subfolder_entry.bind("", lambda _e: self._commit_subfolder()) + self.subfolder_entry.bind("", lambda _e: self._commit_subfolder()) + + self.custom_row = ctk.CTkFrame(body, fg_color="transparent") + self.custom_row.grid(row=2, column=0, sticky="ew", pady=(t.SM, 0)) + self.custom_row.grid_columnconfigure(0, weight=1) + self.custom_label = ctk.CTkLabel(self.custom_row, text="", font=t.font(11), + text_color=t.MUTED, anchor="w") + self.custom_label.grid(row=0, column=0, sticky="w") + ghost_button(self.custom_row, "Browse…", self._pick_folder, height=30, + width=90).grid(row=0, column=1, sticky="e") + + section_label(body, "If a file already exists").grid(row=3, column=0, sticky="w", + pady=(t.MD, t.XS)) + self.existing_buttons = segmented(body, ["Skip", "Overwrite", "Rename"], + self.settings.on_existing.capitalize(), + self._set_existing) + self.existing_buttons.grid(row=4, column=0, sticky="ew") + self._add(card, pady=(0, t.SM)) + + def _set_dest_mode(self, label: str) -> None: + self.settings.dest_mode = DEST_BY_LABEL[label] + if self.settings.dest_mode == "custom" and not self.settings.dest_folder: + self._pick_folder() + self._changed(refresh_preset=False) + + def _pick_folder(self) -> None: + chosen = filedialog.askdirectory(title="Choose an output folder") + if chosen: + self.settings.dest_folder = chosen + self.settings.dest_mode = "custom" + self.dest_menu.set(DEST_LABELS["custom"]) + elif self.settings.dest_mode == "custom" and not self.settings.dest_folder: + self.settings.dest_mode = "subfolder" # cancelled with nothing set + self.dest_menu.set(DEST_LABELS["subfolder"]) + self._changed(refresh_preset=False) + + def _commit_subfolder(self) -> None: + self.settings.subfolder_name = self.subfolder_entry.get().strip() or "Converted" + self._changed(refresh_preset=False) + self.subfolder_entry.delete(0, "end") + self.subfolder_entry.insert(0, self.settings.subfolder_name) + + def _set_existing(self, label: str) -> None: + self.settings.on_existing = label.lower() + self._changed(refresh_preset=False) + + # -- conditional visibility ----------------------------------------- + def _sync_conditional_rows(self) -> None: + """Hide options that can't apply, instead of leaving dead controls on + screen (v1 left the metadata toggle enabled while ignoring it).""" + show = self.settings.resize_mode != "none" + _toggle(self.resize_value_row, show, row=1) + if show: + self._reload_resize_entry() + + _toggle(self.fill_row, self.settings.square_mode == "canvas", row=5) + _toggle(self.subfolder_row, self.settings.dest_mode == "subfolder", row=1) + _toggle(self.custom_row, self.settings.dest_mode == "custom", row=2) + if self.settings.dest_mode == "custom": + folder = self.settings.dest_folder or "No folder chosen" + self.custom_label.configure(text=_ellipsize(folder, 34)) + + # Quality is meaningless in lossless mode, and GPS-stripping is + # meaningless when no metadata is written at all. + _set_enabled(self.quality_row.slider, not self.settings.lossless) + _set_enabled(self.gps_switch, self.settings.keep_metadata) + if self.settings.lossless and self.settings.output_format in ("jpeg",): + self.lossless_switch.variable.set(False) + self.settings.lossless = False + _set_enabled(self.lossless_switch, self.settings.output_format != "jpeg") + + def _reload_controls(self) -> None: + """Push settings back into every widget (after a preset is applied).""" + self._building = True + self.preset_buttons.set(self.settings.matching_preset() + if self.settings.matching_preset() != "Custom" else "") + self.format_buttons.set(self.settings.output_format.upper()) + self.quality_row.set(self.settings.quality) + self.effort_row.set(self.settings.effort) + self.lossless_switch.variable.set(self.settings.lossless) + self.resize_menu.set(RESIZE_LABELS[self.settings.resize_mode]) + self.metadata_switch.variable.set(self.settings.keep_metadata) + self.gps_switch.variable.set(self.settings.strip_gps) + self.square_buttons.set(self.settings.square_mode.capitalize() + if self.settings.square_mode != "off" else "Off") + self._building = False + self._reload_resize_entry() + + +def _toggle(widget, visible: bool, row: int) -> None: + if visible: + widget.grid(row=row, column=0, sticky="ew", pady=(t.SM, 0)) + else: + widget.grid_remove() + + +def _set_enabled(widget, enabled: bool) -> None: + widget.configure(state="normal" if enabled else "disabled") + + +def _ellipsize(text: str, limit: int) -> str: + return text if len(text) <= limit else "…" + text[-(limit - 1):] diff --git a/gui/screens/__init__.py b/gui/screens/__init__.py index 11fe8f3..3878149 100644 --- a/gui/screens/__init__.py +++ b/gui/screens/__init__.py @@ -1 +1 @@ -# Screen modules +"""Screens: home, progress, results.""" diff --git a/gui/screens/dropzone.py b/gui/screens/dropzone.py deleted file mode 100644 index a212dc1..0000000 --- a/gui/screens/dropzone.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -Screen 1: Drop Zone - Main landing screen for file/folder input. - -Features: -- Large drag-and-drop area -- Browse buttons (files/folder) -- Recent folders quick access -- Settings shortcut -""" - -import customtkinter as ctk -from pathlib import Path -from typing import List, Optional, TYPE_CHECKING -from tkinter import filedialog -import platform - -if TYPE_CHECKING: - from gui.app import WebPConverterApp - -# Import config -import sys -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from core.config import config - - -class DropZoneScreen(ctk.CTkFrame): - """ - Drop Zone screen - Main landing page for file selection. - """ - - def __init__(self, parent, app: "WebPConverterApp"): - super().__init__(parent, fg_color="transparent") - self.app = app - self._setup_ui() - - def _setup_ui(self): - """Build the UI components.""" - # Configure grid - self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(1, weight=1) - - # ───────────────────────────────────────────────────────────────────── - # Header - # ───────────────────────────────────────────────────────────────────── - header_frame = ctk.CTkFrame(self, fg_color="transparent") - header_frame.grid(row=0, column=0, sticky="ew", padx=30, pady=(30, 10)) - header_frame.grid_columnconfigure(0, weight=1) - - # App title - title_label = ctk.CTkLabel( - header_frame, - text="WebP Converter", - font=ctk.CTkFont(size=28, weight="bold") - ) - title_label.grid(row=0, column=0, sticky="w") - - # Settings button (gear icon) - settings_btn = ctk.CTkButton( - header_frame, - text="βš™οΈ", - width=40, - height=40, - corner_radius=20, - fg_color="transparent", - hover_color=("gray80", "gray30"), - command=self._open_settings - ) - settings_btn.grid(row=0, column=1, sticky="e") - - # ───────────────────────────────────────────────────────────────────── - # Drop Zone Area - # ───────────────────────────────────────────────────────────────────── - drop_frame = ctk.CTkFrame( - self, - fg_color=("gray90", "gray17"), - corner_radius=20, - border_width=3, - border_color=("gray70", "gray40") - ) - drop_frame.grid(row=1, column=0, sticky="nsew", padx=30, pady=20) - drop_frame.grid_columnconfigure(0, weight=1) - drop_frame.grid_rowconfigure(0, weight=1) - - # Inner content container - inner_frame = ctk.CTkFrame(drop_frame, fg_color="transparent") - inner_frame.place(relx=0.5, rely=0.5, anchor="center") - - # Drop icon (using emoji as placeholder - can replace with actual icon) - drop_icon = ctk.CTkLabel( - inner_frame, - text="πŸ“", - font=ctk.CTkFont(size=64) - ) - drop_icon.pack(pady=(0, 20)) - - # Drop text - drop_text = ctk.CTkLabel( - inner_frame, - text="Drop images or folders here", - font=ctk.CTkFont(size=18, weight="bold") - ) - drop_text.pack(pady=(0, 5)) - - # Subtitle - subtitle = ctk.CTkLabel( - inner_frame, - text="or use the buttons below", - font=ctk.CTkFont(size=13), - text_color=("gray50", "gray60") - ) - subtitle.pack(pady=(0, 30)) - - # Browse buttons container - btn_frame = ctk.CTkFrame(inner_frame, fg_color="transparent") - btn_frame.pack() - - # Browse folder button - browse_folder_btn = ctk.CTkButton( - btn_frame, - text="πŸ“‚ Browse Folder", - font=ctk.CTkFont(size=14), - width=150, - height=40, - corner_radius=10, - command=self._browse_folder - ) - browse_folder_btn.grid(row=0, column=0, padx=10) - - # Browse files button - browse_files_btn = ctk.CTkButton( - btn_frame, - text="πŸ–ΌοΈ Browse Files", - font=ctk.CTkFont(size=14), - width=150, - height=40, - corner_radius=10, - fg_color=("gray70", "gray30"), - hover_color=("gray60", "gray40"), - command=self._browse_files - ) - browse_files_btn.grid(row=0, column=1, padx=10) - - # Make the drop zone clickable - drop_frame.bind("", lambda e: self._browse_folder()) - - # ───────────────────────────────────────────────────────────────────── - # Recent Folders Section - # ───────────────────────────────────────────────────────────────────── - recent_frame = ctk.CTkFrame(self, fg_color="transparent") - recent_frame.grid(row=2, column=0, sticky="ew", padx=30, pady=(0, 20)) - recent_frame.grid_columnconfigure(0, weight=1) - - if config.RECENT_FOLDERS: - recent_label = ctk.CTkLabel( - recent_frame, - text="Recent Folders", - font=ctk.CTkFont(size=14, weight="bold"), - text_color=("gray50", "gray60") - ) - recent_label.grid(row=0, column=0, sticky="w", pady=(0, 10)) - - # Show up to 3 recent folders - for i, folder_path in enumerate(config.RECENT_FOLDERS[:3]): - folder = Path(folder_path) - if folder.exists(): - folder_btn = ctk.CTkButton( - recent_frame, - text=f"πŸ“ {folder.name}", - font=ctk.CTkFont(size=12), - height=30, - corner_radius=8, - fg_color="transparent", - hover_color=("gray80", "gray30"), - text_color=("gray40", "gray70"), - anchor="w", - command=lambda p=folder: self._select_folder(p) - ) - folder_btn.grid(row=i+1, column=0, sticky="ew", pady=2) - - # ───────────────────────────────────────────────────────────────────── - # Footer - # ───────────────────────────────────────────────────────────────────── - footer_frame = ctk.CTkFrame(self, fg_color="transparent") - footer_frame.grid(row=3, column=0, sticky="ew", padx=30, pady=(0, 20)) - footer_frame.grid_columnconfigure(0, weight=1) - - version_label = ctk.CTkLabel( - footer_frame, - text=f"MacAlpha v{self.app.VERSION}", - font=ctk.CTkFont(size=11), - text_color=("gray60", "gray50") - ) - version_label.grid(row=0, column=0, sticky="w") - - # Supported formats hint - formats_label = ctk.CTkLabel( - footer_frame, - text="JPG β€’ PNG β€’ BMP β€’ TIFF β€’ HEIC β€’ WebP", - font=ctk.CTkFont(size=11), - text_color=("gray60", "gray50") - ) - formats_label.grid(row=0, column=1, sticky="e") - - def _browse_folder(self): - """Open folder browser dialog.""" - initial_dir = str(Path.home() / "Downloads") - if config.RECENT_FOLDERS: - recent = Path(config.RECENT_FOLDERS[0]) - if recent.exists(): - initial_dir = str(recent.parent) - - folder = filedialog.askdirectory( - title="Select folder containing images", - initialdir=initial_dir - ) - - if folder: - self._select_folder(Path(folder)) - - def _browse_files(self): - """Open file browser dialog for multiple files.""" - filetypes = [ - ("Image files", "*.jpg *.jpeg *.png *.bmp *.tiff *.tif *.webp *.heic"), - ("All files", "*.*") - ] - - files = filedialog.askopenfilenames( - title="Select images to convert", - filetypes=filetypes, - initialdir=str(Path.home() / "Downloads") - ) - - if files: - paths = [Path(f) for f in files] - self.app.set_source_paths(paths) - - # Add parent folder to recent - if paths: - config.add_recent_folder(paths[0].parent) - - self.app.show_screen("settings") - - def _select_folder(self, folder: Path): - """Handle folder selection.""" - self.app.set_source_paths([folder]) - config.add_recent_folder(folder) - self.app.show_screen("settings") - - def _open_settings(self): - """Open settings screen.""" - self.app.show_screen("settings") - - def on_show(self, **kwargs): - """Called when this screen is shown.""" - pass # Refresh recent folders if needed diff --git a/gui/screens/home.py b/gui/screens/home.py new file mode 100644 index 0000000..74aa9ed --- /dev/null +++ b/gui/screens/home.py @@ -0,0 +1,288 @@ +"""Home: drop zone on the left, live settings on the right, one action bar. + +v1 spread this over two wizard screens, so you could never see your files and +your settings at the same time. +""" + +from __future__ import annotations + +import queue +import threading +from pathlib import Path +from tkinter import filedialog +from typing import TYPE_CHECKING + +import customtkinter as ctk + +from core.config import SOURCE_EXTENSIONS +from core.imaging import readable_extensions +from core.runner import Scan, destination_root, format_bytes, scan_sources +from gui import theme as t +from gui.panel import SettingsPanel +from gui.widgets import Card, ghost_button, primary_button + +if TYPE_CHECKING: + from gui.app import App + +FILE_TYPES = [ + ("Images", "*.jpg *.jpeg *.png *.bmp *.tif *.tiff *.webp *.avif *.heic *.heif *.gif"), + ("All files", "*.*"), +] + +PANEL_WIDTH = 372 +SCAN_POLL_MS = 60 + + +class HomeScreen(ctk.CTkFrame): + def __init__(self, parent, app: "App"): + super().__init__(parent, fg_color="transparent") + self.app = app + self.scan: Scan | None = None + self._scan_token = 0 + self._scan_results: queue.Queue = queue.Queue() + self._scan_pending = False + + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=0, minsize=PANEL_WIDTH + t.MD) + self.grid_rowconfigure(0, weight=1) + + self._build_left() + self._build_panel() + self._build_action_bar() + self._render_sources() + + # -- layout --------------------------------------------------------- + def _build_left(self) -> None: + left = ctk.CTkFrame(self, fg_color="transparent") + left.grid(row=0, column=0, sticky="nsew", padx=(t.XL, t.MD), pady=(t.LG, 0)) + + # pack, not grid: `expand=True` fills the column unambiguously, where a + # weighted grid row here left the card at its requested height. + self.drop_card = ctk.CTkFrame(left, fg_color=t.SURFACE, corner_radius=t.RADIUS, + border_width=2, border_color=t.BORDER_STRONG) + self.drop_card.pack(fill="both", expand=True) + self.drop_card.grid_columnconfigure(0, weight=1) + self.drop_card.grid_rowconfigure(0, weight=1) + + self.drop_inner = ctk.CTkFrame(self.drop_card, fg_color="transparent") + self.drop_inner.place(relx=0.5, rely=0.5, anchor="center") + + self.drop_icon = ctk.CTkLabel(self.drop_inner, text="–", font=t.font(56, "bold"), + text_color=t.BORDER_STRONG) + self.drop_icon.pack() + self.drop_title = ctk.CTkLabel(self.drop_inner, text="Drop images or folders", + font=t.font(19, "bold"), text_color=t.TEXT) + self.drop_title.pack(pady=(t.SM, 2)) + self.drop_subtitle = ctk.CTkLabel(self.drop_inner, text="Folders are searched recursively", + font=t.font(12), text_color=t.MUTED) + self.drop_subtitle.pack(pady=(0, t.LG)) + + buttons = ctk.CTkFrame(self.drop_inner, fg_color="transparent") + buttons.pack() + ghost_button(buttons, "Choose folder", self.browse_folder, height=38, + width=140).grid(row=0, column=0, padx=t.XS) + ghost_button(buttons, "Choose files", self.browse_files, height=38, + width=140).grid(row=0, column=1, padx=t.XS) + + # height=1: an empty CTkFrame defaults to 200px, which silently stole + # a third of the drop zone whenever there were no recent folders. + self.formats_hint = ctk.CTkLabel( + self.drop_card, text=_supported_line(), font=t.font(11), text_color=t.FAINT) + self.formats_hint.place(relx=0.5, rely=1.0, y=-t.MD, anchor="s") + + self.recent_bar = ctk.CTkFrame(left, fg_color="transparent", height=1) + self.recent_bar.pack(fill="x", pady=(t.SM, 0)) + self._render_recent() + + # Clicking anywhere in the empty drop area opens the folder picker. + for widget in (self.drop_card, self.drop_inner, self.drop_icon, + self.drop_title, self.drop_subtitle): + widget.bind("", lambda _e: self._click_drop_area()) + + def _build_panel(self) -> None: + holder = ctk.CTkFrame(self, fg_color="transparent", width=PANEL_WIDTH) + holder.grid(row=0, column=1, sticky="nsew", padx=(0, t.XL), pady=(t.LG, 0)) + holder.pack_propagate(False) + self.panel = SettingsPanel(holder, self.app.settings, self._settings_changed) + self.panel.pack(fill="both", expand=True) + + def _build_action_bar(self) -> None: + bar = ctk.CTkFrame(self, fg_color="transparent") + bar.grid(row=1, column=0, columnspan=2, sticky="ew", padx=t.XL, pady=t.LG) + bar.grid_columnconfigure(0, weight=1) + + self.summary = ctk.CTkLabel(bar, text="No files selected", font=t.font(12), + text_color=t.MUTED, anchor="w", justify="left") + self.summary.grid(row=0, column=0, sticky="w") + + self.convert_button = primary_button(bar, "Convert", self.start, height=46) + self.convert_button.configure(width=190, state="disabled") + self.convert_button.grid(row=0, column=1, sticky="e", padx=(t.MD, 0)) + + def _render_recent(self) -> None: + for child in self.recent_bar.winfo_children(): + child.destroy() + folders = [Path(p) for p in self.app.settings.recent_folders[:4]] + folders = [f for f in folders if f.exists()] + if not folders: + return + ctk.CTkLabel(self.recent_bar, text="Recent", font=t.font(11), + text_color=t.FAINT).grid(row=0, column=0, padx=(2, t.SM)) + for i, folder in enumerate(folders): + ghost_button(self.recent_bar, folder.name, + lambda f=folder: self.set_sources([f]), + height=28).grid(row=0, column=i + 1, padx=2) + + # -- source selection ------------------------------------------------ + def _click_drop_area(self) -> None: + if self.app.sources: + self.clear_sources() + else: + self.browse_folder() + + def browse_folder(self) -> None: + initial = self.app.settings.recent_folders[0] if self.app.settings.recent_folders else str(Path.home()) + chosen = filedialog.askdirectory(title="Choose a folder of images", + initialdir=initial) + if chosen: + self.set_sources([Path(chosen)]) + + def browse_files(self) -> None: + chosen = filedialog.askopenfilenames(title="Choose images", filetypes=FILE_TYPES, + initialdir=str(Path.home())) + if chosen: + self.set_sources([Path(p) for p in chosen]) + + def set_sources(self, paths: list[Path]) -> None: + paths = [p for p in paths if p.exists()] + if not paths: + return + self.app.sources = paths + folder = paths[0] if paths[0].is_dir() else paths[0].parent + self.app.settings.add_recent_folder(folder) + self._render_recent() + self._render_sources() + self._rescan() + + def clear_sources(self) -> None: + self.app.sources = [] + self.scan = None + self._scan_token += 1 # invalidate any in-flight scan + self._scan_pending = False + self._render_sources() + self._update_summary("No files selected", ready=False) + + def handle_drop(self, paths: list[Path]) -> None: + self.set_sources(paths) + + # -- preflight scan -------------------------------------------------- + def _rescan(self) -> None: + """Count files off the Tk thread β€” a network folder with 40k files + would otherwise freeze the window while it walks.""" + self._scan_token += 1 + token = self._scan_token + self._scan_pending = True + sources = list(self.app.sources) + settings = self.app.settings + self._update_summary("Scanning…", ready=False) + + def work() -> None: + exclude = destination_root(sources, settings) + scan = scan_sources(sources, readable_extensions(SOURCE_EXTENSIONS), exclude) + self._scan_results.put((token, scan)) + + threading.Thread(target=work, daemon=True).start() + # Hand the result back through a queue the main thread polls. Calling + # `after()` from the worker reaches into Tk from the wrong thread. + self.after(SCAN_POLL_MS, self._poll_scan) + + def _poll_scan(self) -> None: + try: + token, scan = self._scan_results.get_nowait() + except queue.Empty: + if self._scan_pending: + self.after(SCAN_POLL_MS, self._poll_scan) + return + self._scan_done(token, scan) + if self._scan_pending: + self.after(SCAN_POLL_MS, self._poll_scan) # that was a stale result + + def _scan_done(self, token: int, scan: Scan) -> None: + if token != self._scan_token: + return # a newer scan superseded this one; keep polling for it + self._scan_pending = False + self.scan = scan + self.app.scan = scan + if not scan.files: + self._update_summary("No convertible images found here", ready=False) + return + target = destination_root(self.app.sources, self.app.settings) + where = "beside each original" if target is None else _shorten(target) + self._update_summary( + f"{len(scan.files):,} images Β· {format_bytes(scan.total_bytes)}\nβ†’ {where}", + ready=True) + + def _update_summary(self, text: str, ready: bool) -> None: + self.summary.configure(text=text) + count = len(self.scan.files) if (ready and self.scan) else 0 + self.convert_button.configure( + state="normal" if ready else "disabled", + text=f"Convert {count:,} images" if count else "Convert") + + def _settings_changed(self) -> None: + self.app.settings.save() + if self.app.sources: + self._rescan() # destination and exclusions may have moved + + # -- selected-state rendering ---------------------------------------- + def _render_sources(self) -> None: + selected = bool(self.app.sources) + if not selected: + self.drop_icon.configure(text="–", text_color=t.BORDER_STRONG) + self.drop_title.configure(text="Drop images or folders") + self.drop_subtitle.configure(text="Folders are searched recursively") + self.drop_card.configure(border_color=t.BORDER_STRONG) + return + + names = [p.name for p in self.app.sources[:3]] + extra = len(self.app.sources) - len(names) + listing = ", ".join(names) + (f" +{extra} more" if extra > 0 else "") + self.drop_icon.configure(text="βœ“", text_color=t.ACCENT) + self.drop_title.configure(text=_ellipsize(listing, 46)) + self.drop_subtitle.configure(text="Click here to clear Β· or drop something else") + self.drop_card.configure(border_color=t.ACCENT) + + def highlight_drop(self, active: bool) -> None: + self.drop_card.configure(border_color=t.ACCENT if active else + (t.ACCENT if self.app.sources else t.BORDER_STRONG)) + + # -- go -------------------------------------------------------------- + def start(self) -> None: + if self.scan and self.scan.files: + self.app.settings.save() + self.app.begin_conversion(self.scan) + + def on_show(self) -> None: + self.panel._reload_controls() + self._render_recent() + if self.app.sources: + self._rescan() + + +def _supported_line() -> str: + """Advertise only what this install can actually read β€” HEIC disappears + from the list when pillow-heif isn't there, instead of failing at file 1.""" + names = [e[1:].upper() for e in readable_extensions(SOURCE_EXTENSIONS)] + names = [n for n in names if n not in ("JPEG", "TIF", "HEIF")] + return "Reads " + " Β· ".join(names) + + +def _shorten(path: Path) -> str: + try: + return str(path.relative_to(Path.home()).as_posix()) + except ValueError: + return _ellipsize(str(path), 52) + + +def _ellipsize(text: str, limit: int) -> str: + return text if len(text) <= limit else text[: limit - 1] + "…" diff --git a/gui/screens/progress.py b/gui/screens/progress.py index 7e188bc..9bee673 100644 --- a/gui/screens/progress.py +++ b/gui/screens/progress.py @@ -1,354 +1,168 @@ -""" -Screen 3: Progress - Real-time processing status display. +"""Progress: ring, live counters, ETA, rolling log. -Features: -- Circular progress indicator -- Overall progress bar -- File list with per-file status -- Time remaining estimate -- Cancel button +The runner calls back from worker threads; everything here goes through a +queue and one `after` poll, because Tk is not thread-safe. """ -import customtkinter as ctk -from pathlib import Path -from typing import TYPE_CHECKING, List, Dict, Optional -import threading +from __future__ import annotations + import queue -import time -from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import TYPE_CHECKING + +import customtkinter as ctk + +from core.runner import (CANCELLED, CONVERTED, FAILED, SKIPPED, FileResult, Progress, + Runner, Scan, format_bytes, format_duration) +from gui import theme as t +from gui.widgets import Card, LogView, ProgressRing, StatTile, ghost_button if TYPE_CHECKING: - from gui.app import WebPConverterApp + from gui.app import App -# Import config and processing engine -import sys -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from core.config import config +POLL_MS = 80 +STATUS_MARK = {CONVERTED: "ok ", SKIPPED: "skip", FAILED: "FAIL", CANCELLED: "----"} class ProgressScreen(ctk.CTkFrame): - """ - Progress screen - Shows real-time processing status. - """ - - def __init__(self, parent, app: "WebPConverterApp"): + def __init__(self, parent, app: "App"): super().__init__(parent, fg_color="transparent") self.app = app - self.processing = False - self.cancel_requested = False - self.results = [] - self.update_queue = queue.Queue() + self.runner: Runner | None = None + self.events: queue.Queue = queue.Queue() + self._polling = False + self._last: Progress | None = None - self._setup_ui() - - def _setup_ui(self): - """Build the UI components.""" - # Configure grid self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(2, weight=1) - - # ───────────────────────────────────────────────────────────────────── - # Header - # ───────────────────────────────────────────────────────────────────── - header_frame = ctk.CTkFrame(self, fg_color="transparent") - header_frame.grid(row=0, column=0, sticky="ew", padx=30, pady=(30, 10)) - header_frame.grid_columnconfigure(0, weight=1) - - title_label = ctk.CTkLabel( - header_frame, - text="Converting...", - font=ctk.CTkFont(size=24, weight="bold") - ) - title_label.grid(row=0, column=0, sticky="w") - - # ───────────────────────────────────────────────────────────────────── - # Main Progress Section - # ───────────────────────────────────────────────────────────────────── - progress_card = ctk.CTkFrame( - self, - fg_color=("gray90", "gray17"), - corner_radius=20 - ) - progress_card.grid(row=1, column=0, sticky="ew", padx=30, pady=20) - progress_card.grid_columnconfigure(0, weight=1) - - # Progress percentage - self.progress_label = ctk.CTkLabel( - progress_card, - text="0%", - font=ctk.CTkFont(size=48, weight="bold") - ) - self.progress_label.grid(row=0, column=0, pady=(30, 5)) - - # File count - self.count_label = ctk.CTkLabel( - progress_card, - text="0 of 0 images", - font=ctk.CTkFont(size=16), - text_color=("gray50", "gray60") - ) - self.count_label.grid(row=1, column=0, pady=(0, 10)) - - # Progress bar - self.progress_bar = ctk.CTkProgressBar( - progress_card, - height=12, - corner_radius=6 - ) - self.progress_bar.set(0) - self.progress_bar.grid(row=2, column=0, sticky="ew", padx=40, pady=(10, 10)) - - # Time remaining - self.time_label = ctk.CTkLabel( - progress_card, - text="Calculating...", - font=ctk.CTkFont(size=13), - text_color=("gray50", "gray60") + self.grid_rowconfigure(1, weight=1) + self._build() + + # -- layout --------------------------------------------------------- + def _build(self) -> None: + top = Card(self, "Converting") + top.grid(row=0, column=0, sticky="ew", padx=t.XL, pady=(t.LG, t.MD)) + body = top.body() + body.grid_columnconfigure(0, weight=0) # ring keeps its natural width + body.grid_columnconfigure(1, weight=1) # tiles take the rest + + self.ring = ProgressRing(body, size=168) + self.ring.grid(row=0, column=0, rowspan=2, padx=(t.SM, t.XL)) + + tiles = ctk.CTkFrame(body, fg_color="transparent") + tiles.grid(row=0, column=1, sticky="ew") + tiles.grid_columnconfigure((0, 1, 2, 3), weight=1, uniform="tile") + + self.tile_done = StatTile(tiles, "Converted", "0", t.SUCCESS) + self.tile_saved = StatTile(tiles, "Saved so far", "β€”", t.SUCCESS) + self.tile_eta = StatTile(tiles, "Time left", "β€”") + self.tile_issues = StatTile(tiles, "Skipped / failed", "0 / 0", t.MUTED) + for i, tile in enumerate((self.tile_done, self.tile_saved, self.tile_eta, + self.tile_issues)): + tile.grid(row=0, column=i, sticky="nsew", padx=(0 if i == 0 else t.SM, 0)) + + self.current = ctk.CTkLabel(body, text="", font=t.font(11), text_color=t.MUTED, + anchor="w") + self.current.grid(row=1, column=1, sticky="sw", pady=(t.MD, 0)) + + log_card = Card(self, "Activity") + log_card.grid(row=1, column=0, sticky="nsew", padx=t.XL, pady=(0, t.MD)) + log_body = log_card.body() + log_body.grid_rowconfigure(0, weight=1) + self.log = LogView(log_body) + self.log.grid(row=0, column=0, sticky="nsew") + + bar = ctk.CTkFrame(self, fg_color="transparent") + bar.grid(row=2, column=0, sticky="ew", padx=t.XL, pady=(0, t.LG)) + bar.grid_columnconfigure(0, weight=1) + self.elapsed = ctk.CTkLabel(bar, text="", font=t.font(12), text_color=t.MUTED, + anchor="w") + self.elapsed.grid(row=0, column=0, sticky="w") + self.cancel_button = ctk.CTkButton(bar, text="Stop", command=self.cancel, + height=40, width=140, font=t.font(13, "bold"), + corner_radius=t.RADIUS_SM, + fg_color=t.SURFACE_ALT, hover_color=t.DANGER, + text_color=t.TEXT, border_width=1, + border_color=t.BORDER) + self.cancel_button.grid(row=0, column=1, sticky="e") + + # -- lifecycle ------------------------------------------------------ + def start(self, scan: Scan) -> None: + self.log.clear() + self.ring.set(0, f"of {len(scan.files):,}") + self.tile_done.set("0") + self.tile_saved.set("β€”") + self.tile_eta.set("β€”") + self.tile_issues.set("0 / 0") + self.current.configure(text="") + self.elapsed.configure(text="") + self.cancel_button.configure(text="Stop", state="normal") + self._last = None + + self.events = queue.Queue() + self.runner = Runner( + scan, self.app.settings, + on_progress=lambda p: self.events.put(("progress", p)), + on_file=lambda r: self.events.put(("file", r)), + on_finish=lambda results, cancelled, elapsed: + self.events.put(("finish", (results, cancelled, elapsed))), ) - self.time_label.grid(row=3, column=0, pady=(5, 30)) - - # ───────────────────────────────────────────────────────────────────── - # File List - # ───────────────────────────────────────────────────────────────────── - list_frame = ctk.CTkFrame(self, fg_color="transparent") - list_frame.grid(row=2, column=0, sticky="nsew", padx=30, pady=10) - list_frame.grid_columnconfigure(0, weight=1) - list_frame.grid_rowconfigure(1, weight=1) - - list_label = ctk.CTkLabel( - list_frame, - text="Current Files", - font=ctk.CTkFont(size=14, weight="bold"), - text_color=("gray50", "gray60") - ) - list_label.grid(row=0, column=0, sticky="w", pady=(0, 5)) - - # Scrollable file list - self.file_list = ctk.CTkScrollableFrame( - list_frame, - fg_color=("gray90", "gray17"), - corner_radius=12 - ) - self.file_list.grid(row=1, column=0, sticky="nsew") - self.file_list.grid_columnconfigure(0, weight=1) - - # File entry widgets (pre-create some for reuse) - self.file_entries: Dict[str, ctk.CTkLabel] = {} - - # ───────────────────────────────────────────────────────────────────── - # Footer with Cancel Button - # ───────────────────────────────────────────────────────────────────── - footer_frame = ctk.CTkFrame(self, fg_color="transparent") - footer_frame.grid(row=3, column=0, sticky="ew", padx=30, pady=(10, 30)) - footer_frame.grid_columnconfigure(0, weight=1) - - self.cancel_btn = ctk.CTkButton( - footer_frame, - text="βœ• Cancel", - font=ctk.CTkFont(size=14), - height=40, - corner_radius=10, - fg_color=("gray70", "gray30"), - hover_color=("red", "darkred"), - command=self._cancel_processing - ) - self.cancel_btn.grid(row=0, column=0) - - def _add_file_entry(self, filename: str, status: str = "pending"): - """Add or update a file entry in the list.""" - status_icons = { - "pending": "⏳", - "processing": "πŸ”„", - "done": "βœ…", - "error": "❌", - "skipped": "⏭️" - } - - icon = status_icons.get(status, "⏳") - display_name = filename[:40] + "..." if len(filename) > 40 else filename - - if filename in self.file_entries: - self.file_entries[filename].configure(text=f"{icon} {display_name}") - else: - label = ctk.CTkLabel( - self.file_list, - text=f"{icon} {display_name}", - font=ctk.CTkFont(size=12), - anchor="w" - ) - label.grid(sticky="ew", padx=10, pady=2) - self.file_entries[filename] = label - - def _update_progress(self, completed: int, total: int, current_file: str = ""): - """Update progress display.""" - if total == 0: - return + self.runner.start_background() - percent = int((completed / total) * 100) - self.progress_label.configure(text=f"{percent}%") - self.count_label.configure(text=f"{completed} of {total} images") - self.progress_bar.set(completed / total) + if not self._polling: + self._polling = True + self._poll() - # Update current file - if current_file: - self._add_file_entry(current_file, "processing") + def cancel(self) -> None: + if self.runner: + self.runner.cancel() + self.cancel_button.configure(text="Stopping…", state="disabled") - def _start_conversion(self): - """Start the conversion process in a background thread.""" - self.processing = True - self.cancel_requested = False - self.results = [] - - # Clear file list - for widget in self.file_list.winfo_children(): - widget.destroy() - self.file_entries.clear() - - # Start processing thread - thread = threading.Thread(target=self._run_conversion, daemon=True) - thread.start() - - # Start update loop - self._check_updates() - - def _run_conversion(self): - """Run the actual conversion (in background thread).""" - try: - from core.converter import find_images, process_single_image - except ImportError: - # Fallback - will be implemented - self._simulate_conversion() - return - - source_paths = self.app.source_paths - output_folder = self.app.output_folder - - # Determine root folder - if source_paths and source_paths[0].is_dir(): - root_folder = source_paths[0] - elif source_paths: - root_folder = source_paths[0].parent - else: - root_folder = Path.home() - - # Find all images - images = find_images(source_paths, config.EXTENSIONS) - total = len(images) - - if total == 0: - self.update_queue.put(("done", [])) - return - - # Process images - completed = 0 - start_time = time.time() - - for img_path in images: - if self.cancel_requested: - break - - # Update UI - self.update_queue.put(("progress", completed, total, img_path.name)) - - # Process image - try: - result = process_single_image(img_path, output_folder, config, root_folder) - # Convert dataclass to dict for results screen - result_dict = { - "success": result.success, - "file": result.file_path, - "output_path": result.output_path, - "error": result.error_message, - "original_size": result.original_size, - "output_size": result.output_size, - "was_skipped": result.was_skipped, - "was_copied": result.was_copied, - } - self.results.append(result_dict) - except Exception as e: - self.results.append({ - "success": False, - "error": str(e), - "file": img_path, - "original_size": 0, - "output_size": 0 - }) - - completed += 1 - - # Final update - self.update_queue.put(("done", self.results)) - - def _simulate_conversion(self): - """Simulate conversion for testing when core module not ready.""" - import random - - source_paths = self.app.source_paths - total = 0 - - # Count files - for path in source_paths: - if path.is_dir(): - total += len(list(path.rglob("*"))) - else: - total += 1 - - if total == 0: - total = 10 # Default for testing - - for i in range(total): - if self.cancel_requested: - break - - filename = f"image_{i+1:03d}.jpg" - self.update_queue.put(("progress", i, total, filename)) - time.sleep(0.1) # Simulate processing time - - # Add to results - self.results.append({ - "success": random.random() > 0.1, - "file": filename, - "saved_bytes": random.randint(10000, 500000) - }) - - self.update_queue.put(("done", self.results)) - - def _check_updates(self): - """Check update queue and update UI.""" + # -- event pump ----------------------------------------------------- + def _poll(self) -> None: + latest: Progress | None = None + finish = None try: + # Drain fully each tick and render only the newest progress value β€” + # at 16 workers the queue fills faster than the UI can redraw. while True: - msg = self.update_queue.get_nowait() - - if msg[0] == "progress": - _, completed, total, current_file = msg - self._update_progress(completed, total, current_file) - - elif msg[0] == "done": - _, results = msg - self.processing = False - self.app.show_results(results) - return - + kind, payload = self.events.get_nowait() + if kind == "progress": + latest = payload + elif kind == "file": + self._log_file(payload) + elif kind == "finish": + finish = payload except queue.Empty: pass - # Schedule next check - if self.processing: - self.after(100, self._check_updates) - - def _cancel_processing(self): - """Cancel the ongoing processing.""" - self.cancel_requested = True - self.cancel_btn.configure(text="Cancelling...", state="disabled") - - def on_show(self, **kwargs): - """Called when this screen is shown.""" - # Reset state - self.progress_bar.set(0) - self.progress_label.configure(text="0%") - self.count_label.configure(text="Starting...") - self.time_label.configure(text="Calculating...") - self.cancel_btn.configure(text="βœ• Cancel", state="normal") - - # Start conversion - self._start_conversion() + if latest: + self._render(latest) + if finish: + self._polling = False + results, cancelled, elapsed = finish + self.app.finish_conversion(results, cancelled, elapsed) + return + self.after(POLL_MS, self._poll) + + def _render(self, p: Progress) -> None: + self._last = p + self.ring.set(p.fraction, f"{p.completed:,} of {p.total:,}") + self.tile_done.set(f"{p.converted:,}") + saved = p.bytes_in - p.bytes_out + self.tile_saved.set(format_bytes(saved) if saved > 0 else "β€”") + eta = p.eta_seconds + self.tile_eta.set(format_duration(eta) if eta is not None else "β€”") + self.tile_issues.set(f"{p.skipped:,} / {p.failed:,}") + self.current.configure(text=p.current) + rate = f" Β· {p.rate:.1f} img/s" if p.rate else "" + self.elapsed.configure(text=f"Elapsed {format_duration(p.elapsed)}{rate}") + + def _log_file(self, result: FileResult) -> None: + mark = STATUS_MARK.get(result.status, "?") + detail = "" + if result.status == CONVERTED: + detail = f"{format_bytes(result.source_bytes)} β†’ {format_bytes(result.output_bytes)}" + if result.message: + detail += f" ({result.message})" + elif result.message: + detail = result.message + self.log.append(f"{mark} {result.source.name:<44.44} {detail}") diff --git a/gui/screens/results.py b/gui/screens/results.py index f569eff..3137943 100644 --- a/gui/screens/results.py +++ b/gui/screens/results.py @@ -1,326 +1,188 @@ -""" -Screen 4: Results - Processing summary and completion display. +"""Results: what happened, what went wrong, and where the files went. -Features: -- Success/failure summary -- File size savings stats -- Processing time -- Open output folder button -- Convert more / Done buttons +v1 reported "Conversion Complete!" even after a cancel, and errors were +recorded but never shown. Both are visible here. """ -import customtkinter as ctk -from pathlib import Path -from typing import TYPE_CHECKING, List, Dict, Any -import subprocess +from __future__ import annotations + import platform +import subprocess +from datetime import datetime +from pathlib import Path +from tkinter import filedialog +from typing import TYPE_CHECKING -if TYPE_CHECKING: - from gui.app import WebPConverterApp +import customtkinter as ctk + +from core.runner import (CANCELLED, CONVERTED, FAILED, SKIPPED, FileResult, + format_bytes, format_duration) +from gui import theme as t +from gui.widgets import Card, LogView, StatTile, ghost_button, primary_button -# Import config -import sys -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from core.config import config +if TYPE_CHECKING: + from gui.app import App class ResultsScreen(ctk.CTkFrame): - """ - Results screen - Shows processing summary after completion. - """ - - def __init__(self, parent, app: "WebPConverterApp"): + def __init__(self, parent, app: "App"): super().__init__(parent, fg_color="transparent") self.app = app - self.results: List[Dict[str, Any]] = [] - self._setup_ui() + self.results: list[FileResult] = [] + self.output_folder: Path | None = None - def _setup_ui(self): - """Build the UI components.""" - # Configure grid self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(2, weight=1) - - # ───────────────────────────────────────────────────────────────────── - # Header with Success Icon - # ───────────────────────────────────────────────────────────────────── - header_frame = ctk.CTkFrame(self, fg_color="transparent") - header_frame.grid(row=0, column=0, sticky="ew", padx=30, pady=(40, 10)) - header_frame.grid_columnconfigure(0, weight=1) - - # Success icon - self.status_icon = ctk.CTkLabel( - header_frame, - text="βœ…", - font=ctk.CTkFont(size=64) - ) - self.status_icon.grid(row=0, column=0, pady=(0, 10)) - - # Title - self.title_label = ctk.CTkLabel( - header_frame, - text="Conversion Complete!", - font=ctk.CTkFont(size=24, weight="bold") - ) - self.title_label.grid(row=1, column=0) - - # Subtitle - self.subtitle_label = ctk.CTkLabel( - header_frame, - text="All images converted successfully", - font=ctk.CTkFont(size=14), - text_color=("gray50", "gray60") - ) - self.subtitle_label.grid(row=2, column=0, pady=(5, 0)) - - # ───────────────────────────────────────────────────────────────────── - # Stats Cards - # ───────────────────────────────────────────────────────────────────── - stats_frame = ctk.CTkFrame(self, fg_color="transparent") - stats_frame.grid(row=1, column=0, sticky="ew", padx=30, pady=30) - stats_frame.grid_columnconfigure((0, 1), weight=1) - - # Files stat card - files_card = ctk.CTkFrame( - stats_frame, - fg_color=("gray90", "gray17"), - corner_radius=16 - ) - files_card.grid(row=0, column=0, sticky="nsew", padx=(0, 10), pady=5) - - self.files_count = ctk.CTkLabel( - files_card, - text="0", - font=ctk.CTkFont(size=36, weight="bold") - ) - self.files_count.pack(pady=(20, 5)) - - files_label = ctk.CTkLabel( - files_card, - text="Images Converted", - font=ctk.CTkFont(size=12), - text_color=("gray50", "gray60") - ) - files_label.pack(pady=(0, 20)) - - # Savings stat card - savings_card = ctk.CTkFrame( - stats_frame, - fg_color=("gray90", "gray17"), - corner_radius=16 - ) - savings_card.grid(row=0, column=1, sticky="nsew", padx=(10, 0), pady=5) - - self.savings_percent = ctk.CTkLabel( - savings_card, - text="0%", - font=ctk.CTkFont(size=36, weight="bold"), - text_color=("green", "#4ade80") - ) - self.savings_percent.pack(pady=(20, 5)) - - savings_label = ctk.CTkLabel( - savings_card, - text="Space Saved", - font=ctk.CTkFont(size=12), - text_color=("gray50", "gray60") - ) - savings_label.pack(pady=(0, 20)) - - # ───────────────────────────────────────────────────────────────────── - # Details Section - # ───────────────────────────────────────────────────────────────────── - details_frame = ctk.CTkFrame( - self, - fg_color=("gray90", "gray17"), - corner_radius=16 - ) - details_frame.grid(row=2, column=0, sticky="nsew", padx=30, pady=10) - details_frame.grid_columnconfigure(1, weight=1) - - # Before size - before_label = ctk.CTkLabel( - details_frame, - text="Before:", - font=ctk.CTkFont(size=13), - text_color=("gray50", "gray60") - ) - before_label.grid(row=0, column=0, sticky="w", padx=20, pady=(20, 5)) - - self.before_size = ctk.CTkLabel( - details_frame, - text="--", - font=ctk.CTkFont(size=13, weight="bold") - ) - self.before_size.grid(row=0, column=1, sticky="e", padx=20, pady=(20, 5)) - - # After size - after_label = ctk.CTkLabel( - details_frame, - text="After:", - font=ctk.CTkFont(size=13), - text_color=("gray50", "gray60") - ) - after_label.grid(row=1, column=0, sticky="w", padx=20, pady=5) - - self.after_size = ctk.CTkLabel( - details_frame, - text="--", - font=ctk.CTkFont(size=13, weight="bold"), - text_color=("green", "#4ade80") - ) - self.after_size.grid(row=1, column=1, sticky="e", padx=20, pady=5) - - # Processing time - time_label = ctk.CTkLabel( - details_frame, - text="Time:", - font=ctk.CTkFont(size=13), - text_color=("gray50", "gray60") - ) - time_label.grid(row=2, column=0, sticky="w", padx=20, pady=(5, 20)) - - self.time_value = ctk.CTkLabel( - details_frame, - text="--", - font=ctk.CTkFont(size=13) - ) - self.time_value.grid(row=2, column=1, sticky="e", padx=20, pady=(5, 20)) - - # Output folder - folder_label = ctk.CTkLabel( - details_frame, - text="Output:", - font=ctk.CTkFont(size=13), - text_color=("gray50", "gray60") - ) - folder_label.grid(row=3, column=0, sticky="w", padx=20, pady=(5, 20)) - - self.folder_path = ctk.CTkLabel( - details_frame, - text="--", - font=ctk.CTkFont(size=11), - text_color=("gray50", "gray60") - ) - self.folder_path.grid(row=3, column=1, sticky="e", padx=20, pady=(5, 20)) - - # ───────────────────────────────────────────────────────────────────── - # Footer Buttons - # ───────────────────────────────────────────────────────────────────── - footer_frame = ctk.CTkFrame(self, fg_color="transparent") - footer_frame.grid(row=3, column=0, sticky="ew", padx=30, pady=(10, 30)) - footer_frame.grid_columnconfigure((0, 1, 2), weight=1) - - # Open folder button - open_btn = ctk.CTkButton( - footer_frame, - text="πŸ“‚ Open Folder", - font=ctk.CTkFont(size=14), - height=45, - corner_radius=10, - fg_color=("gray70", "gray30"), - hover_color=("gray60", "gray40"), - command=self._open_output_folder - ) - open_btn.grid(row=0, column=0, sticky="ew", padx=(0, 10)) - - # Convert more button - more_btn = ctk.CTkButton( - footer_frame, - text="πŸ”„ Convert More", - font=ctk.CTkFont(size=14), - height=45, - corner_radius=10, - command=self._convert_more - ) - more_btn.grid(row=0, column=1, sticky="ew", padx=10) - - # Done button - done_btn = ctk.CTkButton( - footer_frame, - text="βœ“ Done", - font=ctk.CTkFont(size=14), - height=45, - corner_radius=10, - fg_color=("green", "#22c55e"), - hover_color=("darkgreen", "#16a34a"), - command=self._done - ) - done_btn.grid(row=0, column=2, sticky="ew", padx=(10, 0)) - - def _format_bytes(self, size_bytes: int) -> str: - """Format byte size as human-readable string.""" - if size_bytes == 0: - return "0 B" - - units = ["B", "KB", "MB", "GB"] - unit_index = 0 - size = float(size_bytes) - - while size >= 1024 and unit_index < len(units) - 1: - size /= 1024 - unit_index += 1 - - return f"{size:.1f} {units[unit_index]}" - - def _update_stats(self, results: List[Dict[str, Any]]): - """Update the stats display with processing results.""" - total = len(results) - successful = sum(1 for r in results if r.get("success", False)) - - # Calculate sizes - total_before = sum(r.get("original_size", 0) for r in results) - total_after = sum(r.get("output_size", 0) for r in results) - saved = total_before - total_after if total_before > 0 else 0 - savings_pct = int((saved / total_before) * 100) if total_before > 0 else 0 - - # Update UI - if successful == total and total > 0: - self.status_icon.configure(text="βœ…") - self.title_label.configure(text="Conversion Complete!") - self.subtitle_label.configure(text="All images converted successfully") - elif successful > 0: - self.status_icon.configure(text="⚠️") - self.title_label.configure(text="Conversion Complete") - self.subtitle_label.configure(text=f"{successful} of {total} images converted") + self._build() + + def _build(self) -> None: + head = ctk.CTkFrame(self, fg_color="transparent") + head.grid(row=0, column=0, sticky="ew", padx=t.XL, pady=(t.XL, t.MD)) + head.grid_columnconfigure(0, weight=1) + self.headline = ctk.CTkLabel(head, text="", font=t.font(26, "bold"), + text_color=t.TEXT, anchor="w") + self.headline.grid(row=0, column=0, sticky="w") + self.subhead = ctk.CTkLabel(head, text="", font=t.font(13), text_color=t.MUTED, + anchor="w") + self.subhead.grid(row=1, column=0, sticky="w", pady=(2, 0)) + + tiles = ctk.CTkFrame(self, fg_color="transparent") + tiles.grid(row=1, column=0, sticky="ew", padx=t.XL) + tiles.grid_columnconfigure((0, 1, 2, 3), weight=1, uniform="tile") + self.tile_files = StatTile(tiles, "Images converted", "0") + self.tile_saved = StatTile(tiles, "Space saved", "β€”", t.SUCCESS) + # Smaller type: "1.2 GB β†’ 240.0 MB" overflows the tile at 24pt. + self.tile_sizes = StatTile(tiles, "Before β†’ after", "β€”", value_size=17) + self.tile_time = StatTile(tiles, "Took", "β€”") + for i, tile in enumerate((self.tile_files, self.tile_saved, self.tile_sizes, + self.tile_time)): + tile.grid(row=0, column=i, sticky="nsew", padx=(0 if i == 0 else t.SM, 0)) + + self.detail_card = Card(self, "Details") + self.detail_card.grid(row=2, column=0, sticky="nsew", padx=t.XL, pady=t.MD) + detail_body = self.detail_card.body() + detail_body.grid_rowconfigure(0, weight=1) + self.detail = LogView(detail_body) + self.detail.grid(row=0, column=0, sticky="nsew") + + bar = ctk.CTkFrame(self, fg_color="transparent") + bar.grid(row=3, column=0, sticky="ew", padx=t.XL, pady=(0, t.LG)) + bar.grid_columnconfigure(0, weight=1) + left = ctk.CTkFrame(bar, fg_color="transparent") + left.grid(row=0, column=0, sticky="w") + ghost_button(left, "Open output folder", self.open_output, height=42, + width=170).grid(row=0, column=0, padx=(0, t.SM)) + self.save_log_button = ghost_button(left, "Save log…", self.save_log, height=42, + width=120) + self.save_log_button.grid(row=0, column=1) + more = primary_button(bar, "Convert more", self.app.go_home, height=42) + more.configure(width=170) + more.grid(row=0, column=1, sticky="e") + + # ------------------------------------------------------------------ + def show(self, results: list[FileResult], cancelled: bool, elapsed: float) -> None: + self.results = results + converted = [r for r in results if r.status == CONVERTED] + skipped = [r for r in results if r.status == SKIPPED] + failed = [r for r in results if r.status == FAILED] + stopped = [r for r in results if r.status == CANCELLED] + + bytes_in = sum(r.source_bytes for r in converted) + bytes_out = sum(r.output_bytes for r in converted) + saved = bytes_in - bytes_out + + # Every branch names all three outcomes it knows about. Reporting + # "all 1 images failed" while quietly ignoring 14 skips is how you get + # a user who thinks the app is broken when it did exactly the right thing. + parts = [] + if skipped: + parts.append(f"{len(skipped):,} already existed") + if failed: + parts.append(f"{len(failed):,} failed") + tail = (" Β· " + " Β· ".join(parts)) if parts else "" + + if cancelled: + self.headline.configure(text="Stopped", text_color=t.WARNING) + self.subhead.configure(text=f"{len(converted):,} converted before you stopped" + f" Β· {len(stopped):,} never started{tail}") + elif failed and converted: + self.headline.configure(text="Finished with errors", text_color=t.WARNING) + self.subhead.configure(text=f"{len(converted):,} converted{tail}") + elif failed: + self.headline.configure(text="Nothing converted", text_color=t.DANGER) + self.subhead.configure(text=f"{len(failed):,} failed" + + (f" Β· {len(skipped):,} already existed" + if skipped else "") + " β€” see details") + elif converted: + self.headline.configure(text="Done", text_color=t.SUCCESS) + self.subhead.configure(text=f"{len(converted):,} images converted{tail}") else: - self.status_icon.configure(text="❌") - self.title_label.configure(text="Conversion Failed") - self.subtitle_label.configure(text="No images were converted") - - self.files_count.configure(text=str(successful)) - self.savings_percent.configure(text=f"{savings_pct}%") - self.before_size.configure(text=self._format_bytes(total_before)) - self.after_size.configure(text=self._format_bytes(total_after)) - - # Output folder - if self.app.output_folder: - folder_name = self.app.output_folder.name - self.folder_path.configure(text=folder_name) - - def _open_output_folder(self): - """Open the output folder in Finder/Explorer.""" - if self.app.output_folder and self.app.output_folder.exists(): - folder = str(self.app.output_folder) - - if platform.system() == "Darwin": # macOS - subprocess.run(["open", folder]) - elif platform.system() == "Windows": - subprocess.run(["explorer", folder]) - else: # Linux - subprocess.run(["xdg-open", folder]) - - def _convert_more(self): - """Go back to drop zone to convert more images.""" - self.app.reset_and_go_home() - - def _done(self): - """Close the application.""" - config.save() - self.app.destroy() - - def on_show(self, results: List[Dict[str, Any]] = None, **kwargs): - """Called when this screen is shown.""" - if results: - self.results = results - self._update_stats(results) + self.headline.configure(text="Nothing to do", text_color=t.MUTED) + self.subhead.configure( + text=f"All {len(skipped):,} images already existed β€” set " + f"β€œIf a file already exists” to Overwrite to redo them" + if skipped else "No images were processed") + + self.tile_files.set(f"{len(converted):,}") + percent = round(saved / bytes_in * 100) if bytes_in else 0 + self.tile_saved.set(f"{percent}%" if saved > 0 else "β€”") + self.tile_sizes.set(f"{format_bytes(bytes_in)} β†’ {format_bytes(bytes_out)}" + if converted else "β€”") + self.tile_time.set(format_duration(elapsed)) + + self.output_folder = next((r.destination.parent for r in converted + if r.destination), None) + self._render_detail(converted, skipped, failed, saved) + + def _render_detail(self, converted, skipped, failed, saved: int) -> None: + self.detail.clear() + if saved > 0: + self.detail.append(f"Saved {format_bytes(saved)} across {len(converted):,} images.") + if self.output_folder: + self.detail.append(f"Output: {self.output_folder}") + if converted or skipped or failed: + self.detail.append("") + + # Problems first β€” that is what the user came to this screen for. + for r in failed: + self.detail.append(f"FAIL {r.source.name:<40.40} {r.message}") + for r in skipped: + self.detail.append(f"skip {r.source.name:<40.40} {r.message}") + notes = [r for r in converted if r.message] + for r in notes: + self.detail.append(f"note {r.source.name:<40.40} {r.message}") + + if not (failed or skipped or notes): + self.detail.append("No warnings. Every image converted cleanly.") + + # ------------------------------------------------------------------ + def open_output(self) -> None: + folder = self.output_folder + if not folder or not folder.exists(): + return + system = platform.system() + if system == "Darwin": + subprocess.run(["open", str(folder)], check=False) + elif system == "Windows": + subprocess.run(["explorer", str(folder)], check=False) + else: + subprocess.run(["xdg-open", str(folder)], check=False) + + def save_log(self) -> None: + default = f"conversion-log-{datetime.now():%Y%m%d-%H%M}.txt" + target = filedialog.asksaveasfilename(defaultextension=".txt", + initialfile=default, + filetypes=[("Text file", "*.txt")]) + if not target: + return + lines: list[str] = [] + for r in self.results: + row = f"{r.status.upper():<10} {r.source}" + if r.destination: + row += f" -> {r.destination} ({format_bytes(r.source_bytes)} -> {format_bytes(r.output_bytes)})" + if r.message: + row += f" [{r.message}]" + lines.append(row) + Path(target).write_text("\n".join(lines), encoding="utf-8") + self.save_log_button.configure(text="Saved βœ“") + self.after(1800, lambda: self.save_log_button.configure(text="Save log…")) diff --git a/gui/screens/settings.py b/gui/screens/settings.py deleted file mode 100644 index 8f74689..0000000 --- a/gui/screens/settings.py +++ /dev/null @@ -1,314 +0,0 @@ -""" -Screen 2: Settings - Configuration options before processing. - -Features: -- Quality slider (1-100) -- Resolution limit dropdown -- WebP method (encoding speed) -- Metadata toggle -- Square mode options (Original/Crop/Canvas) -- Start Processing button -""" - -import customtkinter as ctk -from pathlib import Path -from typing import TYPE_CHECKING -import os - -if TYPE_CHECKING: - from gui.app import WebPConverterApp - -# Import config -import sys -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from core.config import config - - -class SettingsScreen(ctk.CTkFrame): - """ - Settings screen - Configure conversion options before processing. - """ - - def __init__(self, parent, app: "WebPConverterApp"): - super().__init__(parent, fg_color="transparent") - self.app = app - self._setup_ui() - - def _setup_ui(self): - """Build the UI components.""" - # Configure grid - self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(1, weight=1) - - # ───────────────────────────────────────────────────────────────────── - # Header with back button - # ───────────────────────────────────────────────────────────────────── - header_frame = ctk.CTkFrame(self, fg_color="transparent") - header_frame.grid(row=0, column=0, sticky="ew", padx=30, pady=(30, 10)) - header_frame.grid_columnconfigure(1, weight=1) - - # Back button - back_btn = ctk.CTkButton( - header_frame, - text="← Back", - width=80, - height=32, - corner_radius=8, - fg_color="transparent", - hover_color=("gray80", "gray30"), - text_color=("gray40", "gray70"), - command=self._go_back - ) - back_btn.grid(row=0, column=0, sticky="w") - - # Title - title_label = ctk.CTkLabel( - header_frame, - text="Settings", - font=ctk.CTkFont(size=24, weight="bold") - ) - title_label.grid(row=0, column=1, sticky="w", padx=(20, 0)) - - # ───────────────────────────────────────────────────────────────────── - # Scrollable content area - # ───────────────────────────────────────────────────────────────────── - content_frame = ctk.CTkScrollableFrame( - self, - fg_color="transparent", - corner_radius=0 - ) - content_frame.grid(row=1, column=0, sticky="nsew", padx=30, pady=10) - content_frame.grid_columnconfigure(0, weight=1) - - row = 0 - - # ───────────────────────────────────────────────────────────────────── - # Quality Section - # ───────────────────────────────────────────────────────────────────── - quality_section = self._create_section(content_frame, "πŸ“Š Quality", row) - row += 1 - - quality_frame = ctk.CTkFrame(content_frame, fg_color=("gray90", "gray17"), corner_radius=12) - quality_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20)) - quality_frame.grid_columnconfigure(0, weight=1) - row += 1 - - # Quality slider - self.quality_var = ctk.IntVar(value=config.QUALITY) - self.quality_label = ctk.CTkLabel( - quality_frame, - text=f"{config.QUALITY}%", - font=ctk.CTkFont(size=16, weight="bold") - ) - self.quality_label.grid(row=0, column=0, sticky="e", padx=20, pady=(15, 5)) - - quality_slider = ctk.CTkSlider( - quality_frame, - from_=1, - to=100, - variable=self.quality_var, - command=self._on_quality_change - ) - quality_slider.grid(row=1, column=0, sticky="ew", padx=20, pady=(0, 5)) - - quality_hint = ctk.CTkLabel( - quality_frame, - text="Lower = smaller file, Higher = better quality", - font=ctk.CTkFont(size=11), - text_color=("gray50", "gray60") - ) - quality_hint.grid(row=2, column=0, sticky="w", padx=20, pady=(0, 15)) - - # ───────────────────────────────────────────────────────────────────── - # Resolution Section - # ───────────────────────────────────────────────────────────────────── - res_section = self._create_section(content_frame, "πŸ“ Resolution Limit", row) - row += 1 - - res_frame = ctk.CTkFrame(content_frame, fg_color=("gray90", "gray17"), corner_radius=12) - res_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20)) - res_frame.grid_columnconfigure(0, weight=1) - row += 1 - - # Resolution presets - self.resolution_options = { - "Original (No limit)": 500.0, - "4K (8 MP)": 8.0, - "5K (14.7 MP)": 14.7, - "6K (19 MP)": 19.0, - "8K (33 MP)": 33.0, - "Custom": config.TARGET_MEGAPIXELS - } - - # Find current selection - current_res = "Custom" - for name, val in self.resolution_options.items(): - if val == config.TARGET_MEGAPIXELS: - current_res = name - break - - self.res_dropdown = ctk.CTkOptionMenu( - res_frame, - values=list(self.resolution_options.keys()), - command=self._on_resolution_change - ) - self.res_dropdown.set(current_res) - self.res_dropdown.grid(row=0, column=0, sticky="ew", padx=20, pady=15) - - # ───────────────────────────────────────────────────────────────────── - # WebP Method Section - # ───────────────────────────────────────────────────────────────────── - method_section = self._create_section(content_frame, "⚑ Encoding Speed", row) - row += 1 - - method_frame = ctk.CTkFrame(content_frame, fg_color=("gray90", "gray17"), corner_radius=12) - method_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20)) - method_frame.grid_columnconfigure(0, weight=1) - row += 1 - - self.method_var = ctk.IntVar(value=config.WEBP_METHOD) - - method_options = ctk.CTkSegmentedButton( - method_frame, - values=["1 Fast", "2", "3", "4", "5", "6 Best"], - command=self._on_method_change - ) - method_options.set(f"{config.WEBP_METHOD}" if config.WEBP_METHOD not in [1, 6] - else ("1 Fast" if config.WEBP_METHOD == 1 else "6 Best")) - method_options.grid(row=0, column=0, sticky="ew", padx=20, pady=15) - - # ───────────────────────────────────────────────────────────────────── - # Metadata Toggle - # ───────────────────────────────────────────────────────────────────── - meta_section = self._create_section(content_frame, "πŸ’Ύ Metadata", row) - row += 1 - - meta_frame = ctk.CTkFrame(content_frame, fg_color=("gray90", "gray17"), corner_radius=12) - meta_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20)) - meta_frame.grid_columnconfigure(0, weight=1) - row += 1 - - self.metadata_var = ctk.BooleanVar(value=config.KEEP_METADATA) - - meta_switch = ctk.CTkSwitch( - meta_frame, - text="Preserve EXIF & color profiles", - variable=self.metadata_var, - command=self._on_metadata_change - ) - meta_switch.grid(row=0, column=0, sticky="w", padx=20, pady=15) - - # ───────────────────────────────────────────────────────────────────── - # Square Mode Section - # ───────────────────────────────────────────────────────────────────── - square_section = self._create_section(content_frame, "πŸ”² Square Mode", row) - row += 1 - - square_frame = ctk.CTkFrame(content_frame, fg_color=("gray90", "gray17"), corner_radius=12) - square_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20)) - square_frame.grid_columnconfigure(0, weight=1) - row += 1 - - # Determine current mode - if config.ENABLE_CROP_SQUARE: - current_square = "Crop" - elif config.ENABLE_SQUARE_CANVAS: - current_square = "Canvas" - else: - current_square = "Original" - - self.square_options = ctk.CTkSegmentedButton( - square_frame, - values=["Original", "Crop", "Canvas"], - command=self._on_square_change - ) - self.square_options.set(current_square) - self.square_options.grid(row=0, column=0, sticky="ew", padx=20, pady=15) - - # ───────────────────────────────────────────────────────────────────── - # Footer with Start Button - # ───────────────────────────────────────────────────────────────────── - footer_frame = ctk.CTkFrame(self, fg_color="transparent") - footer_frame.grid(row=2, column=0, sticky="ew", padx=30, pady=(10, 30)) - footer_frame.grid_columnconfigure(0, weight=1) - - # Source info - self.source_label = ctk.CTkLabel( - footer_frame, - text="No source selected", - font=ctk.CTkFont(size=12), - text_color=("gray50", "gray60") - ) - self.source_label.grid(row=0, column=0, sticky="w", pady=(0, 10)) - - # Start button - start_btn = ctk.CTkButton( - footer_frame, - text="▢️ Start Processing", - font=ctk.CTkFont(size=16, weight="bold"), - height=50, - corner_radius=12, - command=self._start_processing - ) - start_btn.grid(row=1, column=0, sticky="ew") - - def _create_section(self, parent, title: str, row: int) -> ctk.CTkLabel: - """Create a section header label.""" - label = ctk.CTkLabel( - parent, - text=title, - font=ctk.CTkFont(size=14, weight="bold"), - text_color=("gray40", "gray70") - ) - label.grid(row=row, column=0, sticky="w", pady=(10, 5)) - return label - - def _on_quality_change(self, value): - """Handle quality slider change.""" - val = int(value) - self.quality_label.configure(text=f"{val}%") - config.QUALITY = val - - def _on_resolution_change(self, choice): - """Handle resolution dropdown change.""" - config.TARGET_MEGAPIXELS = self.resolution_options.get(choice, 19.0) - - def _on_method_change(self, choice): - """Handle encoding method change.""" - method_map = {"1 Fast": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6 Best": 6} - config.WEBP_METHOD = method_map.get(choice, 6) - - def _on_metadata_change(self): - """Handle metadata toggle change.""" - config.KEEP_METADATA = self.metadata_var.get() - - def _on_square_change(self, choice): - """Handle square mode change.""" - config.ENABLE_CROP_SQUARE = (choice == "Crop") - config.ENABLE_SQUARE_CANVAS = (choice == "Canvas") - - def _go_back(self): - """Navigate back to drop zone.""" - config.save() - self.app.show_screen("dropzone") - - def _start_processing(self): - """Start the conversion process.""" - config.save() - - if not self.app.source_paths: - return # No source selected - - self.app.start_processing() - - def on_show(self, **kwargs): - """Called when this screen is shown.""" - # Update source label - if self.app.source_paths: - count = len(self.app.source_paths) - if count == 1 and self.app.source_paths[0].is_dir(): - self.source_label.configure(text=f"πŸ“ {self.app.source_paths[0].name}") - else: - self.source_label.configure(text=f"πŸ–ΌοΈ {count} item{'s' if count > 1 else ''} selected") - else: - self.source_label.configure(text="No source selected") diff --git a/gui/theme.py b/gui/theme.py new file mode 100644 index 0000000..3309c57 --- /dev/null +++ b/gui/theme.py @@ -0,0 +1,74 @@ +"""Design tokens. + +Every color is a (light, dark) pair β€” CustomTkinter resolves the right one per +appearance mode, so nothing has to be re-styled when the theme flips. v1 +hardcoded "gray90"/"gray17" in fourteen places; this is that, once. +""" + +from __future__ import annotations + +import customtkinter as ctk + +# --- surfaces -------------------------------------------------------------- +BG = ("#f2f3f6", "#131418") +SURFACE = ("#ffffff", "#1b1d23") +SURFACE_ALT = ("#e9ebf0", "#22252c") +BORDER = ("#dcdfe6", "#2b2e37") +BORDER_STRONG = ("#c3c8d2", "#3a3e49") + +# --- text ------------------------------------------------------------------ +TEXT = ("#15171c", "#f1f2f5") +MUTED = ("#6a7180", "#888f9c") +FAINT = ("#9aa1ad", "#666c78") + +# --- intent ---------------------------------------------------------------- +ACCENT = ("#2563eb", "#3b82f6") +ACCENT_HOVER = ("#1d4ed8", "#2f74e6") +ACCENT_SOFT = ("#e5edff", "#1d2a44") +SUCCESS = ("#15803d", "#4ade80") +WARNING = ("#b45309", "#fbbf24") +DANGER = ("#b91c1c", "#f87171") +DANGER_HOVER = ("#991b1b", "#dc2626") + +# --- spacing scale --------------------------------------------------------- +XS, SM, MD, LG, XL = 4, 8, 14, 20, 28 + +RADIUS = 14 +RADIUS_SM = 9 + + +def font(size: int = 13, weight: str = "normal") -> ctk.CTkFont: + return ctk.CTkFont(size=size, weight=weight) + + +# Tk has no generic "monospace" alias β€” asking for it silently hands back +# Arial, which is why the log columns never lined up. Name real families and +# check them against what this machine has. +_MONO_CANDIDATES = ("Menlo", "SF Mono", "Monaco", # macOS + "Cascadia Mono", "Consolas", # Windows + "DejaVu Sans Mono", "Liberation Mono", # Linux + "Courier New", "Courier") +_mono_family: str | None = None + + +def mono_font(size: int = 11) -> ctk.CTkFont: + global _mono_family + if _mono_family is None: + import tkinter.font as tkfont + + available = {name.lower() for name in tkfont.families()} + _mono_family = next((f for f in _MONO_CANDIDATES if f.lower() in available), + "Courier") + return ctk.CTkFont(family=_mono_family, size=size) + + +def resolve(color: tuple[str, str] | str) -> str: + """Flatten a token to one hex string, for raw Tk widgets (Canvas) that + don't understand CustomTkinter's tuple colors.""" + if isinstance(color, str): + return color + return color[1] if ctk.get_appearance_mode() == "Dark" else color[0] + + +def apply_appearance(theme: str) -> None: + ctk.set_appearance_mode({"light": "Light", "dark": "Dark"}.get(theme, "System")) diff --git a/gui/widgets.py b/gui/widgets.py new file mode 100644 index 0000000..cd22bd7 --- /dev/null +++ b/gui/widgets.py @@ -0,0 +1,245 @@ +"""Reusable pieces. Built once here so screens stay layout-only.""" + +from __future__ import annotations + +import math +from typing import Callable + +import customtkinter as ctk + +from gui import theme as t + + +class Card(ctk.CTkFrame): + """A titled surface panel. The base unit of every screen.""" + + def __init__(self, parent, title: str | None = None, subtitle: str | None = None, **kwargs): + kwargs.setdefault("fg_color", t.SURFACE) + kwargs.setdefault("corner_radius", t.RADIUS) + kwargs.setdefault("border_width", 1) + kwargs.setdefault("border_color", t.BORDER) + super().__init__(parent, **kwargs) + self.grid_columnconfigure(0, weight=1) + self._row = 0 + + if title: + head = ctk.CTkFrame(self, fg_color="transparent") + head.grid(row=0, column=0, sticky="ew", padx=t.MD, pady=(t.MD, t.XS)) + head.grid_columnconfigure(0, weight=1) + ctk.CTkLabel(head, text=title, font=t.font(13, "bold"), text_color=t.TEXT, + anchor="w").grid(row=0, column=0, sticky="w") + if subtitle: + ctk.CTkLabel(head, text=subtitle, font=t.font(11), text_color=t.MUTED, + anchor="e").grid(row=0, column=1, sticky="e") + self._row = 1 + + def body(self) -> ctk.CTkFrame: + """Padded container for the card's content.""" + frame = ctk.CTkFrame(self, fg_color="transparent") + frame.grid(row=self._row, column=0, sticky="nsew", + padx=t.MD, pady=(t.SM if self._row else t.MD, t.MD)) + frame.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(self._row, weight=1) + self._row += 1 + return frame + + +def section_label(parent, text: str) -> ctk.CTkLabel: + label = ctk.CTkLabel(parent, text=text.upper(), font=t.font(10, "bold"), + text_color=t.FAINT, anchor="w") + return label + + +class StatTile(ctk.CTkFrame): + """Big number + caption. Used across progress and results.""" + + def __init__(self, parent, caption: str, value: str = "β€”", + color: tuple[str, str] = t.TEXT, value_size: int = 24): + super().__init__(parent, fg_color=t.SURFACE_ALT, corner_radius=t.RADIUS_SM) + self.grid_columnconfigure(0, weight=1) + self.value_label = ctk.CTkLabel(self, text=value, font=t.font(value_size, "bold"), + text_color=color) + self.value_label.grid(row=0, column=0, pady=(t.MD, 0), padx=t.MD) + ctk.CTkLabel(self, text=caption, font=t.font(11), text_color=t.MUTED).grid( + row=1, column=0, pady=(2, t.MD), padx=t.MD) + + def set(self, value: str) -> None: + self.value_label.configure(text=value) + + +class ProgressRing(ctk.CTkFrame): + """Canvas-drawn progress ring with a percentage in the middle. + + A CTkProgressBar can't show a value inside itself, and this is the one + element the user stares at for the whole run. + """ + + def __init__(self, parent, size: int = 168, thickness: int = 12): + super().__init__(parent, fg_color="transparent") + self.size, self.thickness = size, thickness + self._fraction = 0.0 + self.canvas = ctk.CTkCanvas(self, width=size, height=size, + highlightthickness=0, bd=0) + self.canvas.pack() + + self.percent = ctk.CTkLabel(self, text="0%", font=t.font(34, "bold"), + text_color=t.TEXT, fg_color="transparent") + self.percent.place(relx=0.5, rely=0.44, anchor="center") + self.caption = ctk.CTkLabel(self, text="", font=t.font(11), text_color=t.MUTED, + fg_color="transparent") + self.caption.place(relx=0.5, rely=0.63, anchor="center") + self.redraw() + + def set(self, fraction: float, caption: str = "") -> None: + self._fraction = max(0.0, min(1.0, fraction)) + self.percent.configure(text=f"{round(self._fraction * 100)}%") + if caption: + self.caption.configure(text=caption) + self.redraw() + + def redraw(self) -> None: + pad = self.thickness / 2 + 2 + box = (pad, pad, self.size - pad, self.size - pad) + self.canvas.configure(bg=t.resolve(t.SURFACE)) + self.canvas.delete("all") + self.canvas.create_oval(*box, outline=t.resolve(t.SURFACE_ALT), + width=self.thickness) + if self._fraction > 0: + # -90Β° start, negative extent => clockwise from 12 o'clock. + self.canvas.create_arc(*box, start=90, extent=-359.9 * self._fraction, + style="arc", outline=t.resolve(t.ACCENT), + width=self.thickness) + + +class SliderRow(ctk.CTkFrame): + """Label + live value + slider, kept in sync as one unit.""" + + def __init__(self, parent, label: str, from_: int, to: int, value: int, + on_change: Callable[[int], None], suffix: str = "", hint: str = ""): + super().__init__(parent, fg_color="transparent") + self.grid_columnconfigure(0, weight=1) + self._on_change = on_change + self._suffix = suffix + + ctk.CTkLabel(self, text=label, font=t.font(12), text_color=t.TEXT, + anchor="w").grid(row=0, column=0, sticky="w") + self.value_label = ctk.CTkLabel(self, text=f"{value}{suffix}", + font=t.font(12, "bold"), text_color=t.ACCENT) + self.value_label.grid(row=0, column=1, sticky="e") + + self.slider = ctk.CTkSlider(self, from_=from_, to=to, number_of_steps=to - from_, + command=self._changed, button_color=t.ACCENT, + button_hover_color=t.ACCENT_HOVER, + progress_color=t.ACCENT, fg_color=t.SURFACE_ALT) + self.slider.set(value) + self.slider.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(t.XS, 0)) + + if hint: + # wraplength, or a two-line hint silently loses its second half + ctk.CTkLabel(self, text=hint, font=t.font(10), text_color=t.FAINT, + anchor="w", justify="left", wraplength=300).grid( + row=2, column=0, columnspan=2, sticky="w", pady=(2, 0)) + + def _changed(self, raw: float) -> None: + value = int(round(raw)) + self.value_label.configure(text=f"{value}{self._suffix}") + self._on_change(value) + + def set(self, value: int) -> None: + self.slider.set(value) + self.value_label.configure(text=f"{value}{self._suffix}") + + +def segmented(parent, values: list[str], value: str, + on_change: Callable[[str], None]) -> ctk.CTkSegmentedButton: + widget = ctk.CTkSegmentedButton( + parent, values=values, command=on_change, font=t.font(12), + selected_color=t.ACCENT, selected_hover_color=t.ACCENT_HOVER, + unselected_color=t.SURFACE_ALT, unselected_hover_color=t.BORDER, + text_color=t.TEXT, corner_radius=t.RADIUS_SM, + ) + widget.set(value) + return widget + + +def option_menu(parent, values: list[str], value: str, + on_change: Callable[[str], None], width: int = 150) -> ctk.CTkOptionMenu: + widget = ctk.CTkOptionMenu( + parent, values=values, command=on_change, width=width, font=t.font(12), + fg_color=t.SURFACE_ALT, button_color=t.SURFACE_ALT, + button_hover_color=t.BORDER_STRONG, text_color=t.TEXT, + dropdown_fg_color=t.SURFACE, dropdown_text_color=t.TEXT, + dropdown_hover_color=t.ACCENT_SOFT, corner_radius=t.RADIUS_SM, + ) + widget.set(value) + return widget + + +def primary_button(parent, text: str, command: Callable[[], None], + height: int = 44) -> ctk.CTkButton: + return ctk.CTkButton(parent, text=text, command=command, height=height, + font=t.font(14, "bold"), corner_radius=t.RADIUS_SM, + fg_color=t.ACCENT, hover_color=t.ACCENT_HOVER, + text_color="#ffffff") + + +def ghost_button(parent, text: str, command: Callable[[], None], + height: int = 36, width: int = 0) -> ctk.CTkButton: + kwargs = {"width": width} if width else {} + return ctk.CTkButton(parent, text=text, command=command, height=height, + font=t.font(12), corner_radius=t.RADIUS_SM, + fg_color=t.SURFACE_ALT, hover_color=t.BORDER_STRONG, + text_color=t.TEXT, border_width=1, border_color=t.BORDER, + **kwargs) + + +def switch(parent, text: str, value: bool, on_change: Callable[[bool], None]) -> ctk.CTkSwitch: + var = ctk.BooleanVar(value=value) + widget = ctk.CTkSwitch(parent, text=text, variable=var, font=t.font(12), + text_color=t.TEXT, progress_color=t.ACCENT, + button_color="#ffffff", fg_color=t.BORDER_STRONG, + command=lambda: on_change(var.get())) + widget.variable = var # keep a reference so callers can flip it back + return widget + + +class LogView(ctk.CTkTextbox): + """Append-only log with a hard line cap. + + v1 created one Label per file β€” 5000 images meant 5000 live widgets and a + frozen window. One textbox, trimmed from the top, stays flat. + """ + + MAX_LINES = 400 + + def __init__(self, parent, height: int = 120): + super().__init__(parent, height=height, font=t.mono_font(11), + fg_color=t.SURFACE_ALT, text_color=t.MUTED, + corner_radius=t.RADIUS_SM, border_width=0, wrap="none", + activate_scrollbars=True) + self.configure(state="disabled") + self._lines = 0 + + def append(self, line: str) -> None: + self.configure(state="normal") + self.insert("end", line + "\n") + self._lines += 1 + if self._lines > self.MAX_LINES: + self.delete("1.0", f"{self._lines - self.MAX_LINES + 1}.0") + self._lines = self.MAX_LINES + self.see("end") + self.configure(state="disabled") + + def clear(self) -> None: + self.configure(state="normal") + self.delete("1.0", "end") + self.configure(state="disabled") + self._lines = 0 + + +def relayout_ring_colors(widget) -> None: + """Walk a widget tree and redraw any rings after an appearance change.""" + if isinstance(widget, ProgressRing): + widget.redraw() + for child in widget.winfo_children(): + relayout_ring_colors(child) diff --git a/main.py b/main.py index ebd4cfd..ccd7f7e 100644 --- a/main.py +++ b/main.py @@ -1,48 +1,97 @@ #!/usr/bin/env python3 -""" -ConvertImagesToWebP - MacAlpha v0.1 +"""WebP Studio β€” entry point. -Main entry point for the macOS GUI application. + python main.py launch the app + python main.py --check report which optional features are available """ +from __future__ import annotations + import sys from pathlib import Path -# Ensure the app directory is in the path -APP_DIR = Path(__file__).parent.resolve() -sys.path.insert(0, str(APP_DIR)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) -# Check dependencies before importing -def check_dependencies(): - """Check and report missing dependencies.""" - missing = [] +REQUIRED = {"customtkinter": "customtkinter", "PIL": "Pillow"} +OPTIONAL = { + "tkinterdnd2": ("tkinterdnd2", "drag & drop"), + "piexif": ("piexif", "removing GPS tags while keeping other metadata"), + "pillow_heif": ("pillow-heif", "reading iPhone HEIC files"), +} - try: - import customtkinter - except ImportError: - missing.append("customtkinter") +def tk_problem() -> str | None: + """macOS ships Tk 8.5.9, which renders CustomTkinter as black boxes and + ignores dark mode. Naming it beats letting the user file a bug about it.""" try: - from PIL import Image + import tkinter except ImportError: - missing.append("Pillow") + return ("tkinter is not available. On macOS: brew install python-tk\n" + "On Debian/Ubuntu: sudo apt install python3-tk") + if tkinter.TkVersion < 8.6: + return (f"Tk {tkinter.TkVersion} is too old (8.6+ required) β€” widgets will " + f"render incorrectly.\nOn macOS: brew install python python-tk, then " + f"run this with the Homebrew python3.") + return None + + +def _missing_required() -> list[str]: + missing = [] + for module, package in REQUIRED.items(): + try: + __import__(module) + except ImportError: + missing.append(package) + return missing + + +def check() -> int: + import platform as _platform + + print(f"python {sys.version.split()[0]} on {_platform.system()} " + f"{_platform.machine()}") + problem = tk_problem() + print(f"tk {'PROBLEM β€” ' + problem.splitlines()[0] if problem else 'ok'}") + + missing = _missing_required() + for package in missing: + print(f"missing (required): {package}") + + for module, (package, why) in OPTIONAL.items(): + try: + __import__(module) + print(f"ok {package}") + except ImportError: + print(f"absent {package} β€” no {why}") try: - import piexif + from core.imaging import available_output_formats + + print("writable formats:", ", ".join(available_output_formats())) except ImportError: - missing.append("piexif") + pass + return 1 if missing else 0 + +def main() -> int: + if "--check" in sys.argv: + return check() + + problem = tk_problem() + if problem: + print(problem) + return 1 + + missing = _missing_required() if missing: - print("❌ Missing dependencies:") - for pkg in missing: - print(f" - {pkg}") - print("\nInstall with:") - print(f" pip install {' '.join(missing)}") - sys.exit(1) + print("Missing required packages:", ", ".join(missing)) + print("\n pip install -r requirements.txt\n") + return 1 + from gui.app import main as run -if __name__ == "__main__": - check_dependencies() + return run() - from gui.app import main - main() + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt index 2179475..9d4391a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,13 @@ -# Requirements for ConvertImagesToWebP - MacAlpha v0.1 -# macOS GUI App for WebP Conversion +# WebP Studio 2.0 -# GUI Framework -customtkinter>=5.2.0 +# Required +customtkinter>=5.2.2 +Pillow>=11.3.0 # 11.3 ships native AVIF read/write -# Image Processing (same as v6) -Pillow>=10.0.0 -piexif>=1.1.3 +# Optional, each degrades gracefully if absent (see: python main.py --check) +tkinterdnd2>=0.4.2 # drag & drop onto the window +piexif>=1.1.3 # strip GPS while keeping the rest of the EXIF +pillow-heif>=0.16.0 # read iPhone .heic / .heif -# Drag and drop support for tkinter -tkinterdnd2>=0.3.0 - -# macOS app bundling -py2app>=0.28.0 - -# Optional: for system resource monitoring -psutil>=5.9.0 +# Build only (macOS) +# py2app>=0.28.8 diff --git a/setup.py b/setup.py index 1fc9ccd..cc2c085 100644 --- a/setup.py +++ b/setup.py @@ -1,79 +1,56 @@ -""" -py2app setup script for building macOS .app bundle. - -Usage: - python setup.py py2app +"""py2app bundler for macOS. -This will create a standalone .app in the dist/ folder. + python setup.py py2app -> dist/WebP Studio.app """ -from setuptools import setup +from pathlib import Path -APP = ['main.py'] -APP_NAME = 'ConvertImagesToWebP' -VERSION = '0.1.0' +from setuptools import setup -DATA_FILES = [] +APP_NAME = "WebP Studio" +VERSION = "2.0.0" +HERE = Path(__file__).parent +ICON = HERE / "assets" / "icon.icns" OPTIONS = { - 'argv_emulation': False, - 'iconfile': 'assets/icon.icns', - 'plist': { - 'CFBundleName': APP_NAME, - 'CFBundleDisplayName': 'ConvertImagesToWebP - MacAlpha', - 'CFBundleGetInfoString': 'High-performance image to WebP converter', - 'CFBundleIdentifier': 'com.webpconverter.macalpha', - 'CFBundleVersion': VERSION, - 'CFBundleShortVersionString': VERSION, - 'NSHighResolutionCapable': True, - 'NSRequiresAquaSystemAppearance': False, # Support dark mode - 'LSMinimumSystemVersion': '10.15', # Catalina or later - # File types this app can open - 'CFBundleDocumentTypes': [ - { - 'CFBundleTypeName': 'Image File', - 'CFBundleTypeRole': 'Viewer', - 'LSItemContentTypes': [ - 'public.jpeg', - 'public.png', - 'public.tiff', - 'public.heic', - 'com.microsoft.bmp', - ], - 'LSHandlerRank': 'Alternate', - } - ], + "argv_emulation": False, + "plist": { + "CFBundleName": APP_NAME, + "CFBundleDisplayName": APP_NAME, + "CFBundleIdentifier": "com.webpstudio.app", + "CFBundleVersion": VERSION, + "CFBundleShortVersionString": VERSION, + "NSHighResolutionCapable": True, + "NSRequiresAquaSystemAppearance": False, # allow dark mode + # The real floor is whatever the building Python supports; python.org + # universal2 builds go back to 10.13. v1 claimed 10.15 arbitrarily. + "LSMinimumSystemVersion": "10.13", + "CFBundleDocumentTypes": [{ + "CFBundleTypeName": "Image", + "CFBundleTypeRole": "Viewer", + "LSItemContentTypes": ["public.jpeg", "public.png", "public.tiff", + "public.heic", "org.webmproject.webp", + "com.microsoft.bmp"], + "LSHandlerRank": "Alternate", + }], }, - 'packages': [ - 'customtkinter', - 'PIL', - 'piexif', - 'gui', - 'core', - ], - 'includes': [ - 'tkinter', - 'gui.app', - 'gui.screens.dropzone', - 'gui.screens.settings', - 'gui.screens.progress', - 'gui.screens.results', - 'core.config', - 'core.converter', - ], - 'excludes': [ - 'matplotlib', - 'numpy', - 'scipy', - 'pandas', - 'pytest', - ], + # tkinterdnd2 ships a Tcl extension that py2app only copies when the whole + # package is included, not just the importable module. + "packages": ["customtkinter", "PIL", "tkinterdnd2", "gui", "core"], + "includes": ["tkinter", "piexif"], + # setuptools stays: py2app's own recipes import from it during the build, + # and excluding it is a common cause of a bundle that dies on launch. + "excludes": ["matplotlib", "numpy", "scipy", "pandas", "pytest"], } +# v1 hardcoded an iconfile that was never committed, so every build failed. +if ICON.exists(): + OPTIONS["iconfile"] = str(ICON) + setup( - app=APP, + app=["main.py"], name=APP_NAME, - data_files=DATA_FILES, - options={'py2app': OPTIONS}, - setup_requires=['py2app'], + version=VERSION, + options={"py2app": OPTIONS}, + setup_requires=["py2app"], ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..0b15526 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,226 @@ +"""Engine self-check. No framework: `python tests/test_engine.py`. + +Covers the logic that silently corrupts a batch when wrong β€” sizing math, +destination collisions, the skip/overwrite/rename policies, metadata, cancel. +""" + +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from PIL import Image + +from core.config import Settings +from core.imaging import _target_size, available_output_formats, convert_file +from core.runner import CONVERTED, FAILED, SKIPPED, Runner, scan_sources, common_root + + +def make_image(path: Path, size=(800, 600), color="red", mode="RGB") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + Image.new(mode, size, color).save(path) + return path + + +def test_target_size(): + s = Settings() + s.resize_mode = "none" + assert _target_size(4000, 3000, s) == (4000, 3000) + + s.resize_mode, s.resize_value = "long_edge", 1000 + assert _target_size(4000, 2000, s) == (1000, 500) + assert _target_size(2000, 4000, s) == (500, 1000) + # never upscales + assert _target_size(400, 200, s) == (400, 200) + + s.resize_mode, s.resize_value = "width", 300 + assert _target_size(900, 600, s) == (300, 200) + + s.resize_mode, s.resize_value = "height", 300 + assert _target_size(900, 600, s) == (450, 300) + + s.resize_mode, s.resize_value = "megapixels", 1.0 + w, h = _target_size(4000, 3000, s) + assert 0.98 <= (w * h) / 1_000_000 <= 1.02, (w, h) + print(" target_size ok") + + +def test_settings_clamp(): + s = Settings.from_dict({"quality": 900, "output_format": "gif", "effort": -4, + "on_existing": "nuke", "canvas_fill": "banana"}) + assert s.quality == 100 and s.output_format == "webp" + assert s.effort == 0 and s.on_existing == "skip" + assert s.canvas_fill == "transparent" + assert Settings(output_format="jpeg").output_suffix() == ".jpg" + print(" settings clamp ok") + + +def test_convert_and_resize(tmp: Path): + src = make_image(tmp / "in" / "photo.png", (1600, 900)) + s = Settings(output_format="webp", resize_mode="long_edge", resize_value=800) + out = tmp / "out" / "photo.webp" + convert_file(src, out, s) + with Image.open(out) as img: + assert img.size == (800, 450), img.size + assert img.format == "WEBP" + print(" convert + resize ok") + + +def test_alpha_to_jpeg(tmp: Path): + """RGBA into a format with no alpha must flatten, not raise.""" + src = make_image(tmp / "in" / "alpha.png", (100, 100), (255, 0, 0, 128), "RGBA") + out = tmp / "out" / "alpha.jpg" + convert_file(src, out, Settings(output_format="jpeg")) + with Image.open(out) as img: + assert img.mode == "RGB" + print(" alpha flatten ok") + + +def test_square_modes(tmp: Path): + src = make_image(tmp / "in" / "wide.png", (400, 200)) + for mode, expected in (("crop", (200, 200)), ("canvas", (400, 400))): + out = tmp / "out" / f"{mode}.webp" + convert_file(src, out, Settings(square_mode=mode)) + with Image.open(out) as img: + assert img.size == expected, (mode, img.size) + print(" square modes ok") + + +def test_metadata(tmp: Path): + src = tmp / "in" / "meta.jpg" + src.parent.mkdir(parents=True, exist_ok=True) + img = Image.new("RGB", (60, 60), "blue") + exif = img.getexif() + exif[0x010F] = "SelfCheckCamera" # Make + img.save(src, exif=exif) + + kept = tmp / "out" / "kept.webp" + convert_file(src, kept, Settings(keep_metadata=True)) + with Image.open(kept) as out: + assert out.getexif().get(0x010F) == "SelfCheckCamera", "EXIF was dropped" + + stripped = tmp / "out" / "stripped.webp" + convert_file(src, stripped, Settings(keep_metadata=False)) + with Image.open(stripped) as out: + assert not out.getexif().get(0x010F), "EXIF survived a strip" + print(" metadata keep/strip ok") + + +def test_scan_excludes_output(tmp: Path): + root = tmp / "shoot" + make_image(root / "a.png") + make_image(root / "nested" / "b.png") + make_image(root / "Converted" / "a.webp") + + scan = scan_sources([root], (".png", ".webp"), exclude_under=root / "Converted") + names = {p.name for p in scan.files} + assert names == {"a.png", "b.png"}, names + assert scan.total_bytes > 0 + assert common_root([root / "a.png", root / "nested" / "b.png"]) == root.resolve() + print(" scan excludes output ok") + + +def test_on_existing_policies(tmp: Path): + root = tmp / "batch" + make_image(root / "one.png") + + def run(policy: str) -> list: + s = Settings(on_existing=policy, dest_mode="subfolder", subfolder_name="Out") + scan = scan_sources([root], (".png",), exclude_under=root / "Out") + return Runner(scan, s).run() + + first = run("skip") + assert first[0].status == CONVERTED and first[0].destination.exists() + first_size = first[0].destination.stat().st_size + + assert run("skip")[0].status == SKIPPED + assert run("overwrite")[0].status == CONVERTED + assert (root / "Out" / "one.webp").stat().st_size == first_size + + renamed = run("rename") + assert renamed[0].status == CONVERTED + assert renamed[0].destination.name == "one_1.webp", renamed[0].destination + print(" skip/overwrite/rename ok") + + +def test_collision_within_one_run(tmp: Path): + """one.png and one.jpg both want one.webp β€” neither may be lost.""" + root = tmp / "collide" + make_image(root / "one.png") + make_image(root / "one.jpg") + scan = scan_sources([root], (".png", ".jpg"), exclude_under=root / "Out") + results = Runner(scan, Settings(subfolder_name="Out", on_existing="skip")).run() + + assert all(r.status == CONVERTED for r in results), [r.status for r in results] + outputs = {r.destination.name for r in results} + assert len(outputs) == 2, outputs + print(" in-run collision ok") + + +def test_failure_is_isolated(tmp: Path): + root = tmp / "mixed" + make_image(root / "good.png") + (root / "broken.png").write_bytes(b"this is not a png") + + scan = scan_sources([root], (".png",), exclude_under=root / "Out") + results = Runner(scan, Settings(subfolder_name="Out")).run() + by_status = {r.source.name: r.status for r in results} + + assert by_status["good.png"] == CONVERTED + assert by_status["broken.png"] == FAILED + assert not (root / "Out" / "broken.webp").exists(), "left a truncated file behind" + print(" bad file isolated ok") + + +def test_cancel(tmp: Path): + root = tmp / "many" + for i in range(24): + make_image(root / f"img_{i:02d}.png", (200, 200)) + + scan = scan_sources([root], (".png",), exclude_under=root / "Out") + runner = Runner(scan, Settings(subfolder_name="Out", threads=1)) + runner.cancel() # cancel before it starts: every file must report, none convert + results = runner.run() + + assert len(results) == 24 + assert runner.cancelled + assert sum(1 for r in results if r.status == CONVERTED) == 0 + print(" cancel ok") + + +def test_savings_accounting(tmp: Path): + root = tmp / "stats" + make_image(root / "big.png", (1200, 1200), "green") + scan = scan_sources([root], (".png",), exclude_under=root / "Out") + results = Runner(scan, Settings(subfolder_name="Out", quality=60)).run() + r = results[0] + assert r.source_bytes > 0 and r.output_bytes > 0 + assert r.saved_bytes == r.source_bytes - r.output_bytes + print(f" savings accounting ok ({r.source_bytes} -> {r.output_bytes} bytes)") + + +def main() -> int: + print(f"writable formats: {', '.join(available_output_formats())}\n") + with tempfile.TemporaryDirectory() as raw: + tmp = Path(raw) + test_target_size() + test_settings_clamp() + test_convert_and_resize(tmp / "t1") + test_alpha_to_jpeg(tmp / "t2") + test_square_modes(tmp / "t3") + test_metadata(tmp / "t4") + test_scan_excludes_output(tmp / "t5") + test_on_existing_policies(tmp / "t6") + test_collision_within_one_run(tmp / "t7") + test_failure_is_isolated(tmp / "t8") + test_cancel(tmp / "t9") + test_savings_accounting(tmp / "t10") + print("\nall engine checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_gui_boot.py b/tests/test_gui_boot.py new file mode 100644 index 0000000..4d2a16f --- /dev/null +++ b/tests/test_gui_boot.py @@ -0,0 +1,110 @@ +"""GUI boot + one real conversion, driven through the actual Tk event loop. + + python tests/test_gui_boot.py + +Needs a display. The engine tests cover the conversion logic; this one exists +to catch the things that only break on a specific OS β€” a font family that +doesn't resolve, a widget option a platform's Tk rejects, a theme token that +blows up β€” and to prove the queue/poll plumbing between the worker threads and +the window actually completes a run. +""" + +from __future__ import annotations + +import os +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# Must be set before core.config is imported anywhere. +_CONFIG_DIR = tempfile.mkdtemp(prefix="webpstudio-test-") +os.environ["WEBP_STUDIO_CONFIG_DIR"] = _CONFIG_DIR + +from PIL import Image + +from gui.app import App + + +def build_sources(root: Path, count: int = 8) -> Path: + folder = root / "shoot" + folder.mkdir(parents=True, exist_ok=True) + for i in range(count): + Image.new("RGB", (900, 600), (i * 25 % 255, 90, 200)).save( + folder / f"pic_{i:02d}.jpg", quality=95) + (folder / "broken.png").write_bytes(b"not an image") # must not abort the run + return folder + + +def pump(app: App, seconds: float) -> None: + end = time.time() + seconds + while time.time() < end: + app.update() + time.sleep(0.01) + + +def wait_for(app: App, screen: str, timeout: float = 120.0) -> None: + end = time.time() + timeout + while time.time() < end: + pump(app, 0.1) + if app.current == screen: + return + raise AssertionError(f"stuck on {app.current!r}, expected {screen!r}") + + +def main() -> int: + workspace = Path(tempfile.mkdtemp(prefix="webpstudio-work-")) + source = build_sources(workspace) + + app = App() + app.geometry("1060x760") + pump(app, 0.5) + + # Every screen must construct on this platform, not just the first. + for name in ("home", "progress", "results"): + assert name in app.screens, name + print(" all screens constructed") + + home = app.screens["home"] + home.set_sources([source]) + pump(app, 3.0) + assert home.scan is not None, "preflight scan never returned" + assert len(home.scan.files) == 9, home.scan.files + assert home.convert_button.cget("state") == "normal" + print(f" preflight ok β€” {home.summary.cget('text').splitlines()[0]}") + + home.start() + wait_for(app, "results") + + results = app.screens["results"] + produced = sorted((source / "Converted").glob("*.webp")) + assert len(produced) == 8, [p.name for p in produced] + with Image.open(produced[0]) as img: + assert img.format == "WEBP" and img.size == (900, 600) + assert results.headline.cget("text") == "Finished with errors" # the broken file + print(f" converted {len(produced)} files Β· {results.headline.cget('text')}" + f" Β· {results.subhead.cget('text')}") + + # Second pass must skip rather than redo, and must say so. + app.go_home() + pump(app, 3.0) + home.start() + wait_for(app, "results") + assert "already existed" in results.subhead.cget("text"), results.subhead.cget("text") + print(f" rerun ok β€” {results.subhead.cget('text')}") + + # Theme switching repaints without raising (the ring is a raw Canvas). + for theme in ("Light", "Dark", "System"): + app._set_theme(theme) + pump(app, 0.2) + print(" theme switching ok") + + app.destroy() + print("\ngui boot checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 9b122d28ab096ee5e17c2b58ce301e3df9a68594 Mon Sep 17 00:00:00 2001 From: DhakadG Date: Mon, 10 Aug 2026 18:07:27 +0530 Subject: [PATCH 2/4] Fix drag & drop, which was still dead in v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tkinterdnd2 grafts drop_target_register/dnd_bind onto tkinter.BaseWidget, but tkinter.Tk is not a BaseWidget subclass β€” so the root window never got those methods and every registration raised. The bare `except` around the setup swallowed it and showed "drag & drop unavailable on this build", which reads exactly like the package simply not being installed. It was installed. Mixing TkinterDnD.DnDWrapper into the window class fixes it, and the handler now prints the reason to stderr instead of hiding it. Caught by installing the optional dependency and checking app.dnd_enabled rather than trusting the tagline. test_gui_boot now simulates a real drop, using the brace-quoted string tkdnd actually delivers so a path containing spaces is covered, and asserts that a drop arriving mid-run is ignored rather than silently discarded. Also: the Intel bundle job targeted macos-13, which GitHub has retired. An unknown runner label queues indefinitely instead of failing, so that job hung while the other four passed. Switched to macos-15-intel. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 9 +++++---- gui/app.py | 16 ++++++++++++++-- tests/test_gui_boot.py | 25 +++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a3a6dd1..0f0e731 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,10 +44,11 @@ jobs: fail-fast: false matrix: include: - # macos-13 is Intel, macos-14 is Apple Silicon. py2app bundles the - # running interpreter, so a single runner produces a single-arch app - # that simply will not launch on the other kind of Mac. - - runner: macos-13 + # py2app bundles the running interpreter, so a single runner produces + # a single-arch app that will not launch on the other kind of Mac. + # macos-15-intel is the current Intel label; macos-13 was retired and + # a job targeting it queues forever instead of failing. + - runner: macos-15-intel arch: intel - runner: macos-14 arch: apple-silicon diff --git a/gui/app.py b/gui/app.py index 670d945..5193161 100644 --- a/gui/app.py +++ b/gui/app.py @@ -24,11 +24,20 @@ except Exception: # optional: the app is fully usable without it DND_IMPORTED = False +# tkinterdnd2 grafts drop_target_register/dnd_bind onto tkinter.BaseWidget, but +# tkinter.Tk is not a BaseWidget subclass β€” so the root window never gets them +# unless DnDWrapper is mixed in explicitly. +if DND_IMPORTED: + class _Window(ctk.CTk, TkinterDnD.DnDWrapper): + pass +else: + _Window = ctk.CTk + IS_MAC = platform.system() == "Darwin" MOD = "Command" if IS_MAC else "Control" -class App(ctk.CTk): +class App(_Window): def __init__(self) -> None: super().__init__() self.settings = Settings.load() @@ -119,7 +128,10 @@ def _enable_drag_and_drop(self) -> None: self.dnd_bind("<>", lambda _e: self._highlight(True)) self.dnd_bind("<>", lambda _e: self._highlight(False)) self.dnd_enabled = True - except Exception: + except Exception as exc: + # Print it: a silent except here hid the missing DnDWrapper mixin + # behind a tagline that looked like a normal "not installed" state. + print(f"drag & drop disabled: {type(exc).__name__}: {exc}", file=sys.stderr) self.tagline.configure( text="Batch image conversion Β· drag & drop unavailable on this build") diff --git a/tests/test_gui_boot.py b/tests/test_gui_boot.py index 4d2a16f..5bb9011 100644 --- a/tests/test_gui_boot.py +++ b/tests/test_gui_boot.py @@ -95,6 +95,31 @@ def main() -> int: assert "already existed" in results.subhead.cget("text"), results.subhead.cget("text") print(f" rerun ok β€” {results.subhead.cget('text')}") + # Drag & drop, when the optional package is installed. This regressed + # silently once already: tkinter.Tk is not a BaseWidget, so tkinterdnd2's + # methods never reached the window and the failure looked like "not + # installed". Feed it the brace-quoted string tkdnd actually delivers. + if app.dnd_enabled: + dropped = workspace / "My Dropped Photos" # space in the name on purpose + dropped.mkdir() + Image.new("RGB", (300, 200), "red").save(dropped / "dropped.jpg") + + class _Event: + data = "{" + str(dropped) + "}" + + app._on_drop(_Event()) # ignored: not on the home screen + assert app.sources != [dropped], "a drop mid-run must not be accepted" + + app.go_home() + pump(app, 1.0) + app._on_drop(_Event()) + pump(app, 3.0) + assert [p.name for p in app.sources] == ["My Dropped Photos"], app.sources + assert home.scan and len(home.scan.files) == 1, home.scan + print(" drag & drop ok (path with spaces parsed)") + else: + print(" drag & drop skipped β€” tkinterdnd2 not installed") + # Theme switching repaints without raising (the ring is a raw Canvas). for theme in ("Light", "Dark", "System"): app._set_theme(theme) From f11add6b93f85cc6a583347af38ab6d75797117c Mon Sep 17 00:00:00 2001 From: DhakadG Date: Mon, 10 Aug 2026 18:20:18 +0530 Subject: [PATCH 3/4] Address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteen findings, all valid; each verified against the code before changing anything. Two were already fixed in 9b122d2. Data integrity - The overwrite policy destroyed a good previous output when the new encode failed: the failure handler unlinked the destination unconditionally. Encoding now goes to a .part sidecar and is renamed over the target only on success, so a failed run leaves the previous output untouched. Also makes every write atomic β€” a half-written file never appears at the destination. Stability - An exception escaping _process aborted the entire batch and skipped on_finish, stranding the user on the progress screen with no completion and no results. The likely trigger was the unlink above raising PermissionError on Windows when an antivirus or indexer held the handle. _process can no longer raise, staging cleanup tolerates OSError, and run() fires on_finish from a finally block. - Closing the window during a run cancelled the runner and immediately destroyed the interpreter underneath a worker mid-write. Now joins the batch thread with a 5s timeout first. - save_log let an OSError escape into a console the user cannot see. Reports the failure in the details pane instead. Correctness - Presets could select an output format the installed Pillow cannot encode, the exact failure the capability probe exists to prevent. clamp() now reconciles output_format against writable_formats() (lazy-imported and cached; core.imaging imports core.config). - WebP's 16383px ceiling was hit at encode time with a message from inside the encoder. Checked up front with one that names the limit and the way out. - The lossless toggle was offered for AVIF, where quality=100 is near-lossless, not lossless. It is now WebP-only: JPEG has no lossless mode and PNG is always lossless, so the switch was noise in both. - The quality clamp fell back to 82 while the field default was 85. Performance - Every settings change triggered a full background rescan, so dragging the quality slider spawned one directory walk per pixel of travel β€” punishing on a large or network folder. Only the destination settings change which files are found, so only those trigger a rescan. Measured: 36 slider ticks now cause 0 scans, changing the destination causes exactly 1. Interface - A drop where every path was missing (ejected volume, dead network share) was silently ignored and looked like a broken app. It now says so. Security - Pillow floor raised to 12.3.0. Verified against the GitHub advisory database rather than taken on trust: everything below 12.3.0 carries HIGH advisories including a heap out-of-bounds write in ImageCmsTransform.apply (GHSA-9hw9-ch79-4vh6) and another in crop/paste (GHSA-6r8x-57c9-28j4). This app calls ImageCms on profiled images and crop/paste in the square modes. - Workflow declares permissions: contents: read, and the three checkout steps set persist-credentials: false. No job needs write access. Docs - README: macos-13 -> macos-15-intel, CustomTkinter version reconciled with requirements.txt, and the verification table now reflects that CI has run macOS successfully rather than claiming macOS is untested. Tests: three new engine checks cover the overwrite-preserves-output guarantee, the WebP dimension limit, and that every preset selects a writable format. 15 engine checks and the GUI boot suite pass on Windows with Pillow 12.3.0. Two findings needed no change: the macos-13 runner label and the inaccurate pool.map comment were both fixed in 9b122d2, before this review was posted. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 11 ++++++++ README.md | 12 +++++--- core/config.py | 26 ++++++++++++++++- core/imaging.py | 19 +++++++++++-- core/runner.py | 55 +++++++++++++++++++++++++++--------- gui/app.py | 8 +++++- gui/panel.py | 10 +++++-- gui/screens/home.py | 22 +++++++++++++-- gui/screens/progress.py | 4 ++- gui/screens/results.py | 13 +++++++-- requirements.txt | 5 +++- tests/test_engine.py | 56 ++++++++++++++++++++++++++++++++++++- 12 files changed, 210 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f0e731..22a9b1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,11 +6,17 @@ on: pull_request: workflow_dispatch: +# No job here creates releases, publishes packages, or writes via the API. +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -28,6 +34,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -55,6 +63,8 @@ jobs: runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -79,3 +89,4 @@ jobs: name: WebP-Studio-macOS-${{ matrix.arch }} path: WebP-Studio-macOS-${{ matrix.arch }}.zip retention-days: 14 + diff --git a/README.md b/README.md index f329140..762197b 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ build no longer fails without one. **py2app bundles the interpreter it is run with, so the result is single-arch.** An app built on an M-series Mac will not launch on an Intel Mac and vice versa. -CI therefore builds both (`macos-13` Intel, `macos-14` Apple Silicon) and +CI therefore builds both (`macos-15-intel`, `macos-14` Apple Silicon) and uploads them as separate artifacts. To produce one universal binary instead, build with a universal2 python.org interpreter rather than a Homebrew one. @@ -153,11 +153,15 @@ without a display. | | Status | |---|---| -| Windows 11 Β· Python 3.12 Β· Tk 8.6 Β· CustomTkinter 6.0 | both test suites pass; app driven end to end | -| Engine logic (any OS) | 12 checks, no display required | -| macOS | **not yet run** β€” no Mac available to the author. Push to `main` and the CI matrix will build and smoke-test Intel and Apple Silicon bundles. | +| Windows 11 Β· Python 3.12 Β· Tk 8.6 Β· CustomTkinter 6.0 (`>=5.2.2` required) | both suites pass; app driven end to end | +| Engine logic (any OS) | 15 checks, no display required | +| macOS 14 (Apple Silicon), CI | GUI boot + real conversion pass; `.app` builds and its interpreter starts | +| macOS (Intel), CI | `.app` builds on `macos-15-intel` | | Linux | should work; `test_gui_boot` needs `xvfb` in CI | +Not covered anywhere: a human double-clicking the built `.app`. CI runners have +no window server, so that last step is yours. + ## License MIT. diff --git a/core/config.py b/core/config.py index 9e2d710..ec7ff34 100644 --- a/core/config.py +++ b/core/config.py @@ -101,13 +101,20 @@ def clamp(self) -> None: """Coerce every field back into a legal range. Called after load and before every run, so a hand-edited settings.json can't crash a batch.""" self.output_format = _one_of(self.output_format, OUTPUT_FORMATS, "webp") + # OUTPUT_FORMATS is what the app knows about; writable_formats() is what + # this Pillow build can actually encode. A preset (or an old settings + # file) could otherwise select a format that fails on every file β€” the + # exact thing the capability probe exists to prevent. + writable = writable_formats() + if self.output_format not in writable: + self.output_format = writable[0] self.resize_mode = _one_of(self.resize_mode, RESIZE_MODES, "none") self.square_mode = _one_of(self.square_mode, SQUARE_MODES, "off") self.dest_mode = _one_of(self.dest_mode, DEST_MODES, "subfolder") self.on_existing = _one_of(self.on_existing, ON_EXISTING, "skip") self.theme = _one_of(self.theme, THEMES, "system") - self.quality = _clamp_int(self.quality, 1, 100, 82) + self.quality = _clamp_int(self.quality, 1, 100, 85) # match the field default self.effort = _clamp_int(self.effort, 0, 6, 6) self.threads = _clamp_int(self.threads, 0, 64, 0) self.window_width = _clamp_int(self.window_width, 720, 4000, 940) @@ -198,6 +205,23 @@ def matching_preset(self) -> str: # --------------------------------------------------------------------------- +_writable: tuple[str, ...] | None = None + + +def writable_formats() -> tuple[str, ...]: + """Formats this Pillow build can encode. Imported lazily and cached β€” + core.imaging imports this module, so a top-level import would cycle.""" + global _writable + if _writable is None: + try: + from core.imaging import available_output_formats + + _writable = tuple(available_output_formats()) or OUTPUT_FORMATS + except Exception: + _writable = OUTPUT_FORMATS + return _writable + + def _one_of(value: Any, allowed: tuple[str, ...], fallback: str) -> str: return value if value in allowed else fallback diff --git a/core/imaging.py b/core/imaging.py index 09c2ced..45cefff 100644 --- a/core/imaging.py +++ b/core/imaging.py @@ -191,9 +191,10 @@ def _save_kwargs(settings: Settings, exif: bytes | None, icc: bytes | None) -> d kwargs.update(lossless=True, exact=True) elif fmt == "avif": # Pillow's AVIF `speed` is inverted vs WebP's `method`: 0 is slowest. + # No lossless here: quality=100 is near-lossless, not lossless, and + # calling it "lossless" in the UI would be a lie. The panel only offers + # the toggle for WebP (PNG is lossless by definition). kwargs.update(quality=settings.quality, speed=max(0, 6 - settings.effort)) - if settings.lossless: - kwargs["quality"] = 100 elif fmt == "jpeg": kwargs.update(quality=settings.quality, optimize=True, progressive=True, subsampling="4:4:4" if settings.quality >= 90 else "4:2:0") @@ -209,6 +210,19 @@ def _save_kwargs(settings: Settings, exif: bytes | None, icc: bytes | None) -> d PIL_FORMAT = {"webp": "WEBP", "avif": "AVIF", "jpeg": "JPEG", "png": "PNG"} +# Hard format ceilings. WebP's is a container limit, not a Pillow one, so a +# large panorama or flatbed scan fails at encode time with a message that says +# nothing useful. Check first and explain what to do about it. +MAX_DIMENSION = {"webp": 16383, "jpeg": 65535} + + +def _check_dimensions(width: int, height: int, output_format: str) -> None: + limit = MAX_DIMENSION.get(output_format) + if limit and (width > limit or height > limit): + raise ValueError( + f"{width}x{height} exceeds the {output_format.upper()} limit of " + f"{limit}px β€” set a downscale limit, or choose PNG/AVIF") + def convert_file(source: Path, destination: Path, settings: Settings) -> Encoded: """Convert one image. Raises on failure β€” the runner turns that into a @@ -232,6 +246,7 @@ def convert_file(source: Path, destination: Path, settings: Settings) -> Encoded img = _apply_square(img, settings) img = _normalize_mode(img, settings.output_format, settings) + _check_dimensions(img.width, img.height, settings.output_format) destination.parent.mkdir(parents=True, exist_ok=True) img.save(destination, PIL_FORMAT[settings.output_format], diff --git a/core/runner.py b/core/runner.py index bcaee78..ce0257a 100644 --- a/core/runner.py +++ b/core/runner.py @@ -179,15 +179,18 @@ def run(self) -> list[FileResult]: dest_root = destination_root([self.scan.root], self.settings) workers = self.settings.worker_count() - with ThreadPoolExecutor(max_workers=workers) as pool: - # map() streams lazily enough that cancel takes effect quickly, - # and each worker reports its own completion under the lock. - for _ in pool.map(lambda f: self._process(f, dest_root), self.scan.files): - pass - - elapsed = time.monotonic() - self._started - if self.on_finish: - self.on_finish(self.results, self.cancelled, elapsed) + try: + with ThreadPoolExecutor(max_workers=workers) as pool: + # map() submits every task up front; cancelled files fall through + # _process cheaply rather than being withdrawn from the queue. + for _ in pool.map(lambda f: self._process(f, dest_root), self.scan.files): + pass + finally: + # on_finish must fire even if the pool blows up β€” the progress + # screen waits on it, and without it the user is stranded there. + elapsed = time.monotonic() - self._started + if self.on_finish: + self.on_finish(self.results, self.cancelled, elapsed) return self.results def start_background(self) -> threading.Thread: @@ -197,6 +200,18 @@ def start_background(self) -> threading.Thread: # -- internals ------------------------------------------------------ def _process(self, source: Path, dest_root: Path | None) -> None: + """Never raises. pool.map re-raises in the consuming loop, so a single + escaped exception would abort the batch and skip every remaining file.""" + try: + self._process_one(source, dest_root) + except Exception as exc: + try: + self._record(FileResult(source, FAILED, + message=f"{type(exc).__name__}: {exc}")) + except Exception: + pass + + def _process_one(self, source: Path, dest_root: Path | None) -> None: if self._cancel.is_set(): self._record(FileResult(source, CANCELLED)) return @@ -214,15 +229,19 @@ def _process(self, source: Path, dest_root: Path | None) -> None: message="output already exists")) return + # Encode to a sidecar, then rename over the target. Two reasons: + # a half-written file never appears at the destination, and under the + # overwrite policy a failed encode can no longer destroy the perfectly + # good output from a previous run. + staging = destination.with_name(destination.name + ".part") try: - encoded = convert_file(source, destination, self.settings) + encoded = convert_file(source, staging, self.settings) + staging.replace(destination) output_bytes = destination.stat().st_size self._record(FileResult(source, CONVERTED, destination, source_bytes, output_bytes, encoded.note)) except Exception as exc: - # Half-written output is worse than none β€” a truncated file looks - # valid to a file manager and fails silently later. - destination.unlink(missing_ok=True) + _discard(staging) self._record(FileResult(source, FAILED, source_bytes=source_bytes, message=f"{type(exc).__name__}: {exc}")) @@ -288,6 +307,16 @@ class _Skip(Exception): """Destination exists and the policy says leave it alone.""" +def _discard(path: Path) -> None: + """Delete a staging file, tolerating failure. On Windows an antivirus or + indexer holding the handle raises PermissionError; leaving a stray .part + behind is far better than losing the whole batch to it.""" + try: + path.unlink(missing_ok=True) + except OSError: + pass + + # --------------------------------------------------------------------------- def format_bytes(size: float) -> str: for unit in ("B", "KB", "MB", "GB", "TB"): diff --git a/gui/app.py b/gui/app.py index 5193161..8feff9d 100644 --- a/gui/app.py +++ b/gui/app.py @@ -187,7 +187,13 @@ def go_home(self) -> None: def on_close(self) -> None: progress = self.screens.get("progress") if progress and progress.runner and not progress.runner.cancelled: - progress.runner.cancel() # don't leave workers writing after the window dies + progress.runner.cancel() + # Cancelling only sets a flag; a worker mid-encode keeps writing. + # Give it a moment to land so we don't tear the interpreter down + # underneath a file write. + thread = getattr(progress, "thread", None) + if thread and thread.is_alive(): + thread.join(timeout=5.0) self.settings.window_width = self.winfo_width() self.settings.window_height = self.winfo_height() self.settings.save() diff --git a/gui/panel.py b/gui/panel.py index 6e083a1..373c671 100644 --- a/gui/panel.py +++ b/gui/panel.py @@ -363,10 +363,16 @@ def _sync_conditional_rows(self) -> None: # meaningless when no metadata is written at all. _set_enabled(self.quality_row.slider, not self.settings.lossless) _set_enabled(self.gps_switch, self.settings.keep_metadata) - if self.settings.lossless and self.settings.output_format in ("jpeg",): + + # Only WebP has a real lossless mode here. JPEG has none, and AVIF's + # quality=100 is near-lossless β€” offering the toggle there would have + # promised something the encoder does not deliver. PNG is always + # lossless, so the switch would be noise. + lossless_applies = self.settings.output_format == "webp" + if self.settings.lossless and not lossless_applies: self.lossless_switch.variable.set(False) self.settings.lossless = False - _set_enabled(self.lossless_switch, self.settings.output_format != "jpeg") + _set_enabled(self.lossless_switch, lossless_applies) def _reload_controls(self) -> None: """Push settings back into every widget (after a preset is applied).""" diff --git a/gui/screens/home.py b/gui/screens/home.py index 74aa9ed..c5d1a9b 100644 --- a/gui/screens/home.py +++ b/gui/screens/home.py @@ -41,6 +41,7 @@ def __init__(self, parent, app: "App"): self._scan_token = 0 self._scan_results: queue.Queue = queue.Queue() self._scan_pending = False + self._dest_key: tuple = () self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(1, weight=0, minsize=PANEL_WIDTH + t.MD) @@ -154,8 +155,14 @@ def browse_files(self) -> None: self.set_sources([Path(p) for p in chosen]) def set_sources(self, paths: list[Path]) -> None: + requested = len(paths) paths = [p for p in paths if p.exists()] if not paths: + # Silently ignoring the drop looks like the app is broken. This + # happens with paths from a network share or an ejected volume. + self._update_summary( + f"Could not read {requested} dropped item{'s' if requested != 1 else ''}" + " β€” moved, deleted, or on a disconnected drive?", ready=False) return self.app.sources = paths folder = paths[0] if paths[0].is_dir() else paths[0].parent @@ -182,6 +189,7 @@ def _rescan(self) -> None: self._scan_token += 1 token = self._scan_token self._scan_pending = True + self._dest_key = self._destination_key() sources = list(self.app.sources) settings = self.app.settings self._update_summary("Scanning…", ready=False) @@ -229,10 +237,20 @@ def _update_summary(self, text: str, ready: bool) -> None: state="normal" if ready else "disabled", text=f"Convert {count:,} images" if count else "Convert") + def _destination_key(self) -> tuple: + s = self.app.settings + return (s.dest_mode, s.dest_folder, s.subfolder_name) + def _settings_changed(self) -> None: self.app.settings.save() - if self.app.sources: - self._rescan() # destination and exclusions may have moved + # Only the destination settings change which files are found (the + # output folder is excluded from the scan). Rescanning on every change + # meant one background walk per pixel of quality-slider drag β€” brutal + # on a large or network folder. + key = self._destination_key() + if self.app.sources and key != self._dest_key: + self._dest_key = key + self._rescan() # -- selected-state rendering ---------------------------------------- def _render_sources(self) -> None: diff --git a/gui/screens/progress.py b/gui/screens/progress.py index 9bee673..5f46fe3 100644 --- a/gui/screens/progress.py +++ b/gui/screens/progress.py @@ -28,6 +28,7 @@ def __init__(self, parent, app: "App"): super().__init__(parent, fg_color="transparent") self.app = app self.runner: Runner | None = None + self.thread = None self.events: queue.Queue = queue.Queue() self._polling = False self._last: Progress | None = None @@ -105,7 +106,8 @@ def start(self, scan: Scan) -> None: on_finish=lambda results, cancelled, elapsed: self.events.put(("finish", (results, cancelled, elapsed))), ) - self.runner.start_background() + # kept so the window can wait for it on close + self.thread = self.runner.start_background() if not self._polling: self._polling = True diff --git a/gui/screens/results.py b/gui/screens/results.py index 3137943..8ef1814 100644 --- a/gui/screens/results.py +++ b/gui/screens/results.py @@ -183,6 +183,13 @@ def save_log(self) -> None: if r.message: row += f" [{r.message}]" lines.append(row) - Path(target).write_text("\n".join(lines), encoding="utf-8") - self.save_log_button.configure(text="Saved βœ“") - self.after(1800, lambda: self.save_log_button.configure(text="Save log…")) + try: + Path(target).write_text("\n".join(lines), encoding="utf-8") + except OSError as exc: + # Read-only location, full disk, revoked permission β€” telling the + # user beats a traceback into a console they cannot see. + self.detail.append(f"\nCould not save the log: {exc}") + self.save_log_button.configure(text="Save failed") + else: + self.save_log_button.configure(text="Saved βœ“") + self.after(2200, lambda: self.save_log_button.configure(text="Save log…")) diff --git a/requirements.txt b/requirements.txt index 9d4391a..7009b98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,10 @@ # Required customtkinter>=5.2.2 -Pillow>=11.3.0 # 11.3 ships native AVIF read/write +# 11.3 first shipped native AVIF, but everything below 12.3.0 carries HIGH +# advisories in ImageCms and crop/paste (GHSA-9hw9-ch79-4vh6, +# GHSA-6r8x-57c9-28j4) β€” both of which this app calls on every image. +Pillow>=12.3.0 # Optional, each degrades gracefully if absent (see: python main.py --check) tkinterdnd2>=0.4.2 # drag & drop onto the window diff --git a/tests/test_engine.py b/tests/test_engine.py index 0b15526..0705732 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -15,7 +15,8 @@ from PIL import Image from core.config import Settings -from core.imaging import _target_size, available_output_formats, convert_file +from core.imaging import (_check_dimensions, _target_size, available_output_formats, + convert_file) from core.runner import CONVERTED, FAILED, SKIPPED, Runner, scan_sources, common_root @@ -202,6 +203,56 @@ def test_savings_accounting(tmp: Path): print(f" savings accounting ok ({r.source_bytes} -> {r.output_bytes} bytes)") +def test_overwrite_keeps_good_output_when_encode_fails(tmp: Path): + """The staging-file rename exists for this: under `overwrite`, a failed + encode must not destroy the working output from a previous run.""" + root = tmp / "regress" + src = make_image(root / "one.png", (64, 64), "blue") + settings = Settings(subfolder_name="Out", on_existing="overwrite") + + scan = scan_sources([root], (".png",), exclude_under=root / "Out") + assert Runner(scan, settings).run()[0].status == CONVERTED + good = root / "Out" / "one.webp" + original = good.read_bytes() + + # Corrupt the source so the next encode raises partway through. + src.write_bytes(b"no longer a png") + results = Runner(scan_sources([root], (".png",), exclude_under=root / "Out"), + settings).run() + + assert results[0].status == FAILED + assert good.exists(), "overwrite deleted a valid output on a failed encode" + assert good.read_bytes() == original, "previous output was corrupted" + assert not list((root / "Out").glob("*.part")), "staging file left behind" + print(" overwrite preserves good output ok") + + +def test_webp_dimension_limit(tmp: Path): + """WebP tops out at 16383px. Fail with something actionable, not a + ValueError from deep inside the encoder.""" + settings = Settings(output_format="webp") + try: + _check_dimensions(20000, 100, "webp") + except ValueError as exc: + assert "16383" in str(exc) and "downscale" in str(exc), exc + else: + raise AssertionError("oversized WebP was not rejected") + + _check_dimensions(20000, 100, "png") # PNG has no such ceiling + print(" webp dimension limit ok") + + +def test_format_falls_back_to_a_writable_one(tmp: Path): + """A preset must not select a format this Pillow build cannot encode.""" + settings = Settings.from_dict({"output_format": "avif"}) + assert settings.output_format in available_output_formats() + for name in ("Web", "Balanced", "Archive", "Smallest"): + s = Settings() + s.apply_preset(name) + assert s.output_format in available_output_formats(), (name, s.output_format) + print(" preset formats are all writable ok") + + def main() -> int: print(f"writable formats: {', '.join(available_output_formats())}\n") with tempfile.TemporaryDirectory() as raw: @@ -218,6 +269,9 @@ def main() -> int: test_failure_is_isolated(tmp / "t8") test_cancel(tmp / "t9") test_savings_accounting(tmp / "t10") + test_overwrite_keeps_good_output_when_encode_fails(tmp / "t11") + test_webp_dimension_limit(tmp / "t12") + test_format_falls_back_to_a_writable_one(tmp / "t13") print("\nall engine checks passed") return 0 From 0c45670508aac04c2e138abbc74b122d4e2b491c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:05:46 +0000 Subject: [PATCH 4/4] Add LICENSE file and polish README for public release The README claimed MIT but no LICENSE file existed. Also adds build/license/ python badges, a prebuilt-macOS-artifact section (no signed releases yet, so point at the CI build instead), and a short Contributing note. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PRY53DANtDbuMuYEanvhGz --- LICENSE | 21 +++++++++++++++++++++ README.md | 20 +++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0f71e34 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DhakadG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 762197b..2d34cec 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # WebP Studio 2.0 +[![Build](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml/badge.svg)](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](requirements.txt) + Batch image converter for macOS, Windows and Linux. Drop a folder, pick a preset, get smaller images. @@ -57,6 +61,15 @@ python main.py --check | `piexif` | removing GPS tags while keeping the rest of the EXIF | | `pillow-heif` | reading iPhone `.heic` / `.heif` | +### Prebuilt macOS app + +No signed releases yet β€” grab a build straight from CI instead: +[**Actions β†’ Build β†’ latest run**](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml) +β†’ pick `WebP-Studio-macOS-apple-silicon` or `WebP-Studio-macOS-intel` under +Artifacts. Unzip, then see [Gatekeeper](#the-app-is-damaged-and-cant-be-opened) +below before opening it. Artifacts expire after 14 days, so if the run has +aged out, use `workflow_dispatch` to trigger a fresh one from the Actions tab. + ## macOS **Use Homebrew's Python, not Apple's.** macOS ships Tk 8.5.9; CustomTkinter @@ -162,6 +175,11 @@ without a display. Not covered anywhere: a human double-clicking the built `.app`. CI runners have no window server, so that last step is yours. +## Contributing + +Issues and PRs welcome. `python tests/test_engine.py` should stay green with +no display, and CI runs it plus the GUI boot suite on every PR. + ## License -MIT. +[MIT](LICENSE).