diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..22a9b1a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,92 @@ +name: Build + +on: + push: + branches: ["main"] + 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" + 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 + with: + persist-credentials: false + - 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: + # 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 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - 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..762197b 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,167 @@ -# 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-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. + +`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. - -## Usage +`core/` has no UI imports, so the engine is usable from a script and testable +without a display. -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 +### Notes on behaviour -## Settings +- **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. -| 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 | +## Verified on -## License +| | Status | +|---|---| +| 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 | -MIT License - Feel free to modify and distribute. +Not covered anywhere: a human double-clicking the built `.app`. CI runners have +no window server, so that last step is yours. ---- +## License -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..ec7ff34 100644 --- a/core/config.py +++ b/core/config.py @@ -1,163 +1,249 @@ -""" -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") + # 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, 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) + 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", +} + + +# --------------------------------------------------------------------------- +_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 + + +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..45cefff --- /dev/null +++ b/core/imaging.py @@ -0,0 +1,254 @@ +"""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. + # 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)) + 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"} + +# 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 + 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) + _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], + **_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..ce0257a --- /dev/null +++ b/core/runner.py @@ -0,0 +1,335 @@ +"""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() + 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: + 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: + """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 + + 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 + + # 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, 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: + _discard(staging) + 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 _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"): + 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..8feff9d 100644 --- a/gui/app.py +++ b/gui/app.py @@ -1,153 +1,210 @@ -""" -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. - """ +# 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 - VERSION = "0.1.0" - APP_NAME = "ConvertImagesToWebP - MacAlpha" +IS_MAC = platform.system() == "Darwin" +MOD = "Command" if IS_MAC else "Control" - def __init__(self): - super().__init__() - # Configure window - self.title(self.APP_NAME) - self.geometry(f"{config.WINDOW_WIDTH}x{config.WINDOW_HEIGHT}") - self.minsize(500, 600) +class App(_Window): + def __init__(self) -> None: + super().__init__() + self.settings = Settings.load() + self.sources: list[Path] = [] + self.scan: Scan | None = None - # 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 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") + + 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() + # 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() 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..373c671 --- /dev/null +++ b/gui/panel.py @@ -0,0 +1,407 @@ +"""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) + + # 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, lossless_applies) + + 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..c5d1a9b --- /dev/null +++ b/gui/screens/home.py @@ -0,0 +1,306 @@ +"""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._dest_key: tuple = () + + 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: + 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 + 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 + self._dest_key = self._destination_key() + 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 _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() + # 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: + 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..5f46fe3 100644 --- a/gui/screens/progress.py +++ b/gui/screens/progress.py @@ -1,354 +1,170 @@ -""" -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.thread = 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.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.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.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 - - 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) - - # Update current file - if current_file: - self._add_file_entry(current_file, "processing") - - 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.""" + # kept so the window can wait for it on close + self.thread = self.runner.start_background() + + if not self._polling: + self._polling = True + self._poll() + + def cancel(self) -> None: + if self.runner: + self.runner.cancel() + self.cancel_button.configure(text="Stopping…", state="disabled") + + # -- 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..8ef1814 100644 --- a/gui/screens/results.py +++ b/gui/screens/results.py @@ -1,326 +1,195 @@ -""" -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) + 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/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..7009b98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,16 @@ -# 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 +# 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 -# 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..0705732 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,280 @@ +"""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 (_check_dimensions, _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 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: + 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") + 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 + + +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..5bb9011 --- /dev/null +++ b/tests/test_gui_boot.py @@ -0,0 +1,135 @@ +"""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')}") + + # 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) + pump(app, 0.2) + print(" theme switching ok") + + app.destroy() + print("\ngui boot checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())