Repository: ggand0/viewskater Branch: main Commit: 0f7b001a4ad0 Files: 80 Total size: 1.1 MB Directory structure: gitextract_szegwn3j/ ├── .gitignore ├── Cargo.toml ├── README.md ├── assets/ │ └── ViewSkater.icns ├── build.rs ├── docs/ │ ├── bundling.md │ └── replay.md ├── resources/ │ ├── linux/ │ │ ├── appimage.desktop │ │ └── viewskater.desktop │ └── macos/ │ ├── Info.plist │ ├── entitlements.plist │ └── viewskater_wrapper.sh └── src/ ├── app/ │ ├── keyboard_handlers.rs │ ├── message.rs │ ├── message_handlers.rs │ ├── replay_handlers.rs │ └── settings_widget.rs ├── app.rs ├── archive_cache.rs ├── build_info.rs ├── cache/ │ ├── cache_utils.rs │ ├── compression.rs │ ├── cpu_img_cache.rs │ ├── gpu_img_cache.rs │ ├── img_cache.rs │ ├── mod.rs │ └── texture_cache.rs ├── coco/ │ ├── annotation_manager.rs │ ├── mod.rs │ ├── overlay/ │ │ ├── bbox_overlay.rs │ │ ├── bbox_shader.rs │ │ ├── bbox_shader.wgsl │ │ ├── mask_shader.rs │ │ ├── mask_shader.wgsl │ │ ├── mod.rs │ │ ├── polygon_shader.rs │ │ └── polygon_shader.wgsl │ ├── parser.rs │ ├── rle_decoder.rs │ └── widget.rs ├── config.rs ├── exif_utils.rs ├── file_io.rs ├── loading_handler.rs ├── loading_status.rs ├── logging.rs ├── macos_file_access.rs ├── main.rs ├── menu.rs ├── navigation_keyboard.rs ├── navigation_slider.rs ├── pane.rs ├── replay.rs ├── selection_manager.rs ├── settings.rs ├── settings_modal.rs ├── ui.rs ├── utils/ │ ├── mem.rs │ ├── mod.rs │ ├── save.rs │ └── timing.rs ├── widgets/ │ ├── circular.rs │ ├── dualslider.rs │ ├── easing.rs │ ├── mod.rs │ ├── modal.rs │ ├── selection_widget.rs │ ├── shader/ │ │ ├── atlas_texture.wgsl │ │ ├── cpu_scene.rs │ │ ├── image_shader.rs │ │ ├── mod.rs │ │ ├── scene.rs │ │ ├── texture.wgsl │ │ ├── texture_pipeline.rs │ │ └── texture_scene.rs │ ├── split.rs │ ├── synced_image_split.rs │ ├── toggler.rs │ └── viewer.rs └── window_state.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ CLAUDE.md .DS_Store icon.png assets_dev/ benchmarks/ devlogs/ docs/plans/ logs/ tmp/ # Generated by Cargo # will have compiled files and executables debug/ target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk # MSVC Windows builds of rustc generate these, which store debugging information *.pdb/target ================================================ FILE: Cargo.toml ================================================ [package] name = "viewskater" version = "0.3.1" edition = "2021" description = "A fast image viewer for browsing large collections of images." # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] env_logger = "0.10" console_log = "1.0" log = "0.4.20" tokio = { version = "1.32", features = ["rt", "sync", "macros", "time", "io-util", "fs"] } smol = "2.0" rfd = { version = "0.12", default-features = false, features = ["xdg-portal"] } native-dialog = "0.7" num-traits = "0.2" alphanumeric-sort = "1.5.3" image = { version = "0.25", default-features = false, features = [ "jpeg", "png", "gif", "bmp", "ico", "tiff", "webp", "pnm", "qoi", "tga" ] } futures = "0.3" once_cell = "1.16" smol_str = "0.2.2" backtrace = "0.3" dirs = "5.0" webbrowser = "0.7" bytemuck = { version = "1.0", features = ["derive"] } earcutr = "0.4" lyon_algorithms = "1.0" chrono = { version = "0.4", features = ["clock"] } memmap2 = "0.9.5" rayon = "1.8" texpresso = { version = "2.0.1", features = ["rayon"] } sysinfo = "0.33.1" libc = "0.2" clap = { version = "4.0", features = ["derive"] } zip = "4" unrar = "0.5" sevenz-rust2 = "0.18" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" regex = "1.10" arboard = { version = "3", features = ["image-data"] } jpeg2k = { version = "0.10", optional = true, features = ["image"] } # Custom iced (direct deps) iced_custom = { package = "iced", git = "https://github.com/ggand0/iced.git", branch = "custom-0.13", features = [ "image", "tokio", "svg", "lazy", "wgpu" ] } iced_winit = { package = "iced_winit", git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_wgpu = { package = "iced_wgpu", git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_widget = { package = "iced_widget", git = "https://github.com/ggand0/iced.git", branch = "custom-0.13", features = ["wgpu", "canvas"] } iced_core = { package = "iced_core", git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_runtime = { package = "iced_runtime", git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_futures = { package = "iced_futures", git = "https://github.com/ggand0/iced.git", branch = "custom-0.13", features = ["tokio"] } iced_graphics = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } # Upstream iced_aw -- iced deps overridden via [patch.crates-io] below iced_aw = { package = "iced_aw", git = "https://github.com/iced-rs/iced_aw.git", tag = "v.0.11.0", default-features = false, features = [ "menu", "quad", "tabs" ] } # Override iced crates from crates.io (used by iced_aw and iced_fonts) with custom fork [patch.crates-io] iced = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_core = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_widget = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_runtime = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_renderer = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_futures = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_graphics = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_wgpu = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_tiny_skia = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } iced_winit = { git = "https://github.com/ggand0/iced.git", branch = "custom-0.13" } # Local iced development: uncomment below, comment above #iced = { path = "../iced" } #iced_core = { path = "../iced/core" } #iced_widget = { path = "../iced/widget" } #iced_runtime = { path = "../iced/runtime" } #iced_renderer = { path = "../iced/renderer" } #iced_futures = { path = "../iced/futures" } #iced_graphics = { path = "../iced/graphics" } #iced_wgpu = { path = "../iced/wgpu" } #iced_tiny_skia = { path = "../iced/tiny_skia" } #iced_winit = { path = "../iced/winit" } [features] # Image selection/curation features for dataset preparation (disabled by default) selection = [] # COCO dataset visualization (disabled by default) coco = [] # JPEG 2000 support (disabled by default) jp2 = ["dep:jpeg2k"] [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.5.2", features = ["relax-sign-encoding"] } block2 = "0.5" objc2-foundation = { version = "0.2.2", default-features = false, features = [ "std", "NSUserDefaults", "NSNotification", "NSString", "NSOperation", "block2", ] } objc2-app-kit = { version = "0.2.2", default-features = false, features = [ "std", "NSApplication", "NSWindow", "NSView", "NSResponder", "NSScreen", ] } # Used on macOS for generating .app bundles via `cargo bundle` [target.'cfg(target_os = "macos")'.dev-dependencies] cargo-bundle = "0.6.0" [build-dependencies] winres = "0.1.12" chrono = { version = "0.4", features = ["clock"] } [package.metadata.winres] OriginalFilename = "view_skater.exe" FileDescription = "A fast image viewer for browsing large collections of images." [package.metadata.bundle] name = "ViewSkater" identifier = "com.ggando.viewskater" icon = ["assets/ViewSkater.icns"] short_description = "A fast image viewer for browsing large collections of images." [package.metadata.appimage] desktop_entry = "./resources/linux/appimage.desktop" [profile.release] lto = "fat" codegen-units = 1 # Use an optimized dev profile for faster runtime during development. [profile.dev] opt-level = 3 lto = false codegen-units = 16 [profile.opt-dev] inherits = "release" opt-level = 3 lto = false codegen-units = 16 ================================================ FILE: README.md ================================================ Alt text # ViewSkater ViewSkater is a fast, cross-platform image viewer written in Rust & Iced. It aims to alleviate the challenges of exploring and comparing numerous images. Linux, macOS and Windows are currently supported. > **Note:** This (iced) version is in maintenance mode. Active development is moving to the [egui version](https://github.com/ggand0/viewskater-egui), which offers better performance and a simpler codebase. ## Features - GPU-based image rendering powered by wgpu - Dynamic image caching on CPU or GPU memory - Continuous image rendering via key presses and the slider UI - Dual pane view for side-by-side image comparison - Supports image formats supported by the image crate (JPG, PNG, GIF, BMP, TIFF, WebP, QOI, TGA, etc.) - **JPEG 2000 support** (optional feature): View JP2, J2K, and J2C files - Supports viewing images inside ZIP, RAR, and 7z (LZMA2 codec) files - Renders images up to 8192×8192 px (larger images are resized to fit) - **COCO annotation support** (optional feature): Display bounding boxes and segmentation masks with dual rendering modes (polygon/pixel) - **Selection feature** (optional feature): Select and export subsets of images from large datasets ## Installation Download the pre-built binaries from the [releases page](https://github.com/ggand0/viewskater/releases), or build it locally: ```sh cargo run ``` To see debug logs while running, set the `RUST_LOG` environment variable: ```sh RUST_LOG=viewskater=debug cargo run ``` To build a full release binary for packaging or distribution: ```sh cargo build --release ``` **Building with optional features:** ```sh # Build with COCO annotation support cargo build --release --features coco # Build with selection feature cargo build --release --features selection # Build with JPEG 2000 support cargo build --release --features jp2 # Build with multiple features cargo build --release --features coco,selection,jp2 ``` See [docs/bundling.md](./docs/bundling.md) for full packaging instructions. ### Linux icon setup On GNOME 46+ (Ubuntu 24.04+), the taskbar icon requires installing a `.desktop` file and icon: ```bash mkdir -p ~/.local/share/icons/hicolor/256x256/apps cp assets/icon_256.png ~/.local/share/icons/hicolor/256x256/apps/viewskater.png gtk-update-icon-cache -f ~/.local/share/icons/hicolor/ cp resources/linux/viewskater.desktop ~/.local/share/applications/ ``` Edit the `Exec=` line in the installed `.desktop` file to point to your binary: ```bash sed -i "s|Exec=.*|Exec=/path/to/viewskater %f|" \ ~/.local/share/applications/viewskater.desktop ``` For the AppImage, use the AppImage path instead: ```bash sed -i "s|Exec=.*|Exec=/path/to/ViewSkater.AppImage %f|" \ ~/.local/share/applications/viewskater.desktop ``` ## Usage Drag and drop an image or a directory of images onto a pane, and navigate through the images using the **A / D** keys or the slider UI. Use the mouse wheel to zoom in/out of an image. In dual-pane mode (**Ctrl + 2**), the slider syncs images in both panes by default. You can switch to per-pane sliders by selecting the "Controls -> Controls -> Toggle Slider" menu item or pressing the **Space** bar. **COCO Annotations** (when built with `--features coco`): Drag and drop a COCO-format JSON annotation file onto the app. The app will automatically search for the image directory in common locations: - Same directory as the JSON file - `images/`, `img/`, `val2017/`, or `train2017/` subdirectories - Single subdirectory if only one exists in the JSON's parent directory If the image directory is not found automatically, a folder picker will prompt you to select the image directory manually. **Image Selection** (when built with `--features selection`): Mark images for dataset curation while browsing. Press **S** to mark an image as selected (green badge), **X** to exclude it (red badge), or **U** to clear the mark. Export your selections to JSON using **Cmd+E** (macOS) or **Ctrl+E** (Windows/Linux). Selection states are automatically saved and persist across sessions. ## Shortcuts | Action | macOS Shortcut | Windows/Linux Shortcut | |------------------------------------|----------------------|-------------------------| | Show previous / next image | Left / Right or A / D | Left / Right or A / D | | Continuous scroll ("skate" mode) | Shift + Left / Right or Shift + A / D | Shift + Left / Right or Shift + A / D | | Jump to first / last image | Cmd + Left / Right | Ctrl + Left / Right | | Toggle UI (slider + footer) | Tab | Tab | | Toggle single / dual slider | Space | Space | | Select Pane 1 / 2 (Dual slider) | 1 / 2 | 1 / 2 | | Open folder in Pane 1 / 2 | Alt + 1 / 2 | Alt + 1 / 2 | | Open file in Pane 1 / 2 | Shift + Alt + 1 / 2 | Shift + Alt + 1 / 2 | | Open file (Single pane) | Cmd + O | Ctrl + O | | Open folder (Single pane) | Cmd + Shift + O | Ctrl + Shift + O | | Toggle single / dual pane mode | Cmd + 1 / 2 | Ctrl + 1 / 2 | | Toggle fullscreen mode | F11 | F11 | | Close all panes | Cmd + W | Ctrl + W | | Exit | Cmd + Q | Ctrl + Q | ## Documentation - [Bundling & Packaging](./docs/bundling.md) - Build for Linux, macOS, Windows - [Replay Mode](./docs/replay.md) - Automated benchmarking with CLI options ## Resources - [Website](https://viewskater.com/) - [egui version](https://github.com/ggand0/viewskater-egui) ## Acknowledgments ViewSkater's slider UI was inspired by the open-source project [emulsion](https://github.com/ArturKovacs/emulsion). ## License ViewSkater is licensed under either of - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) at your option. ================================================ FILE: build.rs ================================================ use { std::{ env, io, process::Command, fs, }, winres::WindowsResource, }; fn main() -> io::Result<()> { // Capture build information capture_build_info(); // Windows resource setup if env::var_os("CARGO_CFG_WINDOWS").is_some() { WindowsResource::new() // This path can be absolute, or relative to your crate root. // When building on Windows, comment out the first 3 lines of this block (just call set_icon) //.set_toolkit_path("/usr/bin") //.set_windres_path("x86_64-w64-mingw32-windres") //.set_ar_path("x86_64-w64-mingw32-ar") .set_icon("./assets/icon.ico") .compile()?; } Ok(()) } fn capture_build_info() { // Uncomment to override version string if needed: //println!("cargo:rustc-env=CARGO_PKG_VERSION={}", env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".to_string())); // Generate build timestamp let build_timestamp = chrono::Utc::now().format("%Y%m%d.%H%M%S").to_string(); println!("cargo:rustc-env=BUILD_TIMESTAMP={}", build_timestamp); // Get git commit hash let git_hash = get_git_hash().unwrap_or_else(|| "unknown".to_string()); println!("cargo:rustc-env=GIT_HASH={}", git_hash); // Get git commit hash (short version) let git_hash_short = if git_hash.len() >= 7 { git_hash[0..7].to_string() } else { git_hash.clone() }; println!("cargo:rustc-env=GIT_HASH_SHORT={}", git_hash_short); // Target platform info let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| "unknown".to_string()); let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| "unknown".to_string()); println!("cargo:rustc-env=TARGET_PLATFORM={}-{}", target_arch, target_os); // Build profile let profile = env::var("PROFILE").unwrap_or_else(|_| "unknown".to_string()); println!("cargo:rustc-env=BUILD_PROFILE={}", profile); // Create a combined build string let build_string = format!("{}.{}", env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string()), build_timestamp); println!("cargo:rustc-env=BUILD_STRING={}", build_string); // For macOS, automatically update Info.plist with the build timestamp if target_os == "macos" { update_info_plist(&build_timestamp); println!("cargo:rustc-env=BUNDLE_VERSION={}", build_timestamp); } else { // For non-macOS, still set the bundle version but don't update plist println!("cargo:rustc-env=BUNDLE_VERSION={}", build_timestamp); } // Tell cargo to rerun this if git changes or Info.plist changes println!("cargo:rerun-if-changed=.git/HEAD"); println!("cargo:rerun-if-changed=.git/refs/heads/"); println!("cargo:rerun-if-changed=resources/macos/Info.plist"); } fn get_git_hash() -> Option { let output = Command::new("git") .args(["rev-parse", "HEAD"]) .output() .ok()?; if output.status.success() { let hash = String::from_utf8(output.stdout).ok()?; Some(hash.trim().to_string()) } else { None } } fn update_info_plist(build_timestamp: &str) { let plist_path = "resources/macos/Info.plist"; // Check if Info.plist exists if !std::path::Path::new(plist_path).exists() { println!("cargo:warning=Info.plist not found at {}, skipping update", plist_path); return; } // Read the current Info.plist let content = match fs::read_to_string(plist_path) { Ok(content) => content, Err(e) => { println!("cargo:warning=Failed to read Info.plist: {}", e); return; } }; // Update CFBundleVersion using regex replacement let updated_content = if let Some(start) = content.find("CFBundleVersion") { if let Some(value_start) = content[start..].find("") { if let Some(value_end) = content[start + value_start + 8..].find("") { let before = &content[..start + value_start + 8]; let after = &content[start + value_start + 8 + value_end..]; format!("{}{}{}", before, build_timestamp, after) } else { content } } else { content } } else { content }; // Write back the updated content if let Err(e) = fs::write(plist_path, updated_content) { println!("cargo:warning=Failed to write updated Info.plist: {}", e); } else { println!("cargo:warning=Updated CFBundleVersion in Info.plist to {}", build_timestamp); } } ================================================ FILE: docs/bundling.md ================================================ # Building and Packaging ViewSkater This guide explains how to build and package ViewSkater for different operating systems. ## Linux To build and package ViewSkater as an AppImage: ```sh cargo install cargo-appimage cargo appimage ``` This will generate a standalone `.AppImage` in the `target/appimage/` directory. ## Windows To build the release binary: ```sh cargo build --release ``` This will generate `viewskater.exe` in the `target/release/` directory. You can wrap it in an installer using tools like [Inno Setup](https://github.com/jrsoftware/issrc) if needed. ## macOS To bundle the `.app` and create a `.dmg`: ```sh cargo bundle --release ``` Then: ```sh cd target/release/bundle/osx hdiutil create -volname "ViewSkater" -srcfolder "ViewSkater.app" -ov -format UDZO "view_skater.dmg" ``` This will generate a `.dmg` file suitable for distribution. ## Notes - Tested on Rust 1.85.1 - macOS bundling uses [`cargo-bundle`](https://github.com/burtonageo/cargo-bundle) - Linux packaging uses [`cargo-appimage`](https://github.com/linuxwolf/cargo-appimage) ================================================ FILE: docs/replay.md ================================================ # Replay Mode Replay mode automates image navigation for performance benchmarking. It loads test directories, navigates through images, and records FPS metrics. ## Quick Start ```bash # Basic benchmark (keyboard mode) cargo run --profile opt-dev -- --replay --test-dir /path/to/images --duration 5 --auto-exit # Slider mode benchmark cargo run --profile opt-dev -- --replay --test-dir /path/to/images --duration 5 --nav-mode slider --auto-exit # Multiple directories with JSON output cargo run --profile opt-dev -- --replay \ --test-dir /path/to/small_images \ --test-dir /path/to/large_images \ --duration 10 \ --output results.json \ --output-format json \ --auto-exit ``` ## CLI Arguments | Argument | Default | Description | |----------|---------|-------------| | `--replay` | - | Enable replay mode | | `--test-dir` | - | Directory to benchmark (can specify multiple) | | `--duration` | `10` | Seconds to navigate each direction | | `--directions` | `right` | Navigation direction: `right`, `left`, or `both` | | `--iterations` | `1` | Number of times to repeat the benchmark | | `--nav-mode` | `keyboard` | Navigation mechanism: `keyboard` or `slider` | | `--nav-interval` | `50` | Milliseconds between navigation actions | | `--slider-step` | `1` | Images to skip per navigation (slider mode only) | | `--skip-initial` | `0` | Skip first N images from metrics (excludes cache warmup) | | `--output` | - | Output file path | | `--output-format` | `markdown` | Output format: `json` or `markdown` | | `--auto-exit` | `false` | Exit automatically when benchmark completes | | `--verbose` | `false` | Print detailed metrics during execution | ## Navigation Modes ### Keyboard Mode (default) Simulates holding down navigation keys. Sets `skate_right`/`skate_left` flags for continuous frame-by-frame navigation. - Movement: Continuous (every frame) - Image loading: Incremental, cache-aware - Typical Image FPS: 200+ ### Slider Mode Simulates dragging the navigation slider. Sends `SliderChanged` messages at regular intervals. - Movement: Stepped (one position per interval) - Image loading: Direct jump with async preview loading - Typical Image FPS: 20-50 (depends on image size) ## Speed Control Navigation speed is controlled by two parameters: ### Navigation Interval (`--nav-interval`) Controls how frequently navigation actions are triggered. | nav-interval | Actions/sec | |--------------|-------------| | 50ms (default) | 20 | | 25ms | 40 | | 20ms | 50 | | 100ms | 10 | ### Slider Step (`--slider-step`, slider mode only) Controls how many images to skip per navigation action. | nav-interval | slider-step | Images/sec | |--------------|-------------|------------| | 50ms | 1 | 20 | | 50ms | 5 | 100 | | 20ms | 1 | 50 | **Formula:** `images_per_second = (1000 / nav_interval) * slider_step` ### Matching Mouse Speed For MX Master 3 (1000 DPI) or similar mice with typical drag speed: ```bash # ~50 images/sec to match typical mouse drag --nav-mode slider --nav-interval 20 ``` ## Output Formats ### JSON ```json { "results": [ { "directory": "/path/to/images", "direction": "right", "duration_secs": 5.02, "total_frames": 251, "ui_fps": { "avg": 60.1, "min": 58.0, "max": 62.0 }, "image_fps": { "avg": 45.2, "min": 40.0, "max": 50.0, "last": 48.0 }, "memory_mb": { "avg": 512.0, "min": 400.0, "max": 600.0 } } ], "iterations": 2 } ``` ### Markdown Generates a formatted table with per-directory results and summary statistics. ## Metrics Collected | Metric | Description | |--------|-------------| | UI FPS | Main application frame rate | | Image FPS | Rate of new images displayed | | Memory | Process memory usage (Linux/macOS) | | Duration | Actual time spent navigating | | Total Frames | Number of UI frames rendered | ### Image FPS Sources - **Keyboard mode**: Uses `IMAGE_RENDER_FPS` (incremental cache loading) - **Slider mode**: Uses `iced_wgpu::get_image_fps()` (async preview loading) ## Tips 1. **Use `--skip-initial`** to exclude warmup frames where cache is cold 2. **Use `--directions right`** to avoid cache advantage on reverse navigation 3. **Run multiple `--iterations`** for more reliable averages 4. **Use `--profile opt-dev`** for optimized builds with debug symbols 5. **Set `--auto-exit`** for scripted benchmarks ================================================ FILE: resources/linux/appimage.desktop ================================================ [Desktop Entry] Name=ViewSkater Exec=viewskater Icon=icon Type=Application Categories=Graphics;Viewer; StartupWMClass=viewskater ================================================ FILE: resources/linux/viewskater.desktop ================================================ [Desktop Entry] Name=ViewSkater Exec=/path/to/viewskater %f Icon=viewskater Type=Application Categories=Graphics;Viewer; StartupWMClass=viewskater MimeType=image/png;image/jpeg;image/webp;image/tiff;image/bmp; ================================================ FILE: resources/macos/Info.plist ================================================ CFBundleDevelopmentRegion English CFBundleDisplayName ViewSkater CFBundleIconFile ViewSkater.icns CFBundleIdentifier com.ggando.viewskater CFBundleInfoDictionaryVersion 6.0 CFBundleName ViewSkater CFBundleExecutable viewskater CFBundlePackageType APPL CFBundleShortVersionString 0.3.1 CFBundleVersion 20251027.113543 CSResourcesFileMapped LSApplicationCategoryType public.app-category.productivity LSArchitecturePriority arm64 LSMinimumSystemVersion 12.0 NSHighResolutionCapable NSPrincipalClass NSApplication CFBundleDocumentTypes CFBundleTypeName JPEG Image CFBundleTypeRole Viewer LSHandlerRank Alternate CFBundleTypeExtensions jpg jpeg LSItemContentTypes public.jpeg CFBundleTypeIconFile ViewSkater.icns CFBundleTypeName PNG Image CFBundleTypeRole Viewer LSHandlerRank Alternate CFBundleTypeExtensions png LSItemContentTypes public.png CFBundleTypeIconFile ViewSkater.icns UTImportedTypeDeclarations UTTypeConformsTo public.image public.data UTTypeDescription Image File UTTypeIdentifier com.ggando.viewskater.image UTTypeTagSpecification public.filename-extension jpg jpeg png public.mime-type image/jpeg image/png ================================================ FILE: resources/macos/entitlements.plist ================================================ com.apple.security.app-sandbox com.apple.application-identifier YOUR_TEAM_ID.YOUR_BUNDLE_ID com.apple.security.files.user-selected.read-write com.apple.security.files.bookmarks.app-scope com.apple.security.files.bookmarks.document-scope com.apple.security.files.user-selected.read-only ================================================ FILE: resources/macos/viewskater_wrapper.sh ================================================ #!/bin/bash # # viewskater_wrapper.sh # # Purpose: # This wrapper script is intended for debugging macOS file association # events (e.g., when opening a file with ViewSkater from Finder) and # for capturing early startup logs or errors from the main application binary. # # Usage: # Revise Info.plist's CFBundleExecutable to point to this script. # e.g., # CFBundleExecutable # viewskater_wrapper.sh # # How it works: # - Sets up a log file at $HOME/Library/Logs/ViewSkater/open_events.log. # - Logs invocation arguments and environment details. # - Executes the main "viewskater" binary located in the same directory # as this script. # - Redirects stderr of the main binary to the log file. # BASEDIR=$(dirname "$0") LOG_FILE="$HOME/Library/Logs/ViewSkater/open_events.log" mkdir -p "$(dirname "$LOG_FILE")" # Log launch information with more detail echo "$(date): ViewSkater wrapper launched with args: $@" >> "$LOG_FILE" echo "$(date): Current directory: $(pwd)" >> "$LOG_FILE" echo "$(date): Executable path: $BASEDIR/viewskater" >> "$LOG_FILE" # Add direct console output echo "ViewSkater wrapper starting..." echo "Arguments: $@" echo "Current directory: $(pwd)" echo "Executable path: $BASEDIR/viewskater" # Check if the executable exists if [ ! -f "$BASEDIR/viewskater" ]; then echo "ERROR - Executable not found at $BASEDIR/viewskater" echo "$(date): ERROR - Executable not found at $BASEDIR/viewskater" >> "$LOG_FILE" exit 1 fi # Execute with error logging echo "Launching ViewSkater executable..." exec "$BASEDIR/viewskater" "$@" 2>> "$LOG_FILE" ================================================ FILE: src/app/keyboard_handlers.rs ================================================ use log::debug; use iced_core::keyboard::{self, Key, key::Named}; use iced_winit::runtime::Task; use crate::app::{DataViewer, Message}; use crate::menu::PaneLayout; use crate::file_io; use crate::navigation_keyboard::{move_right_all, move_left_all}; // Helper function to check for the platform-appropriate modifier key fn is_platform_modifier(modifiers: &keyboard::Modifiers) -> bool { #[cfg(target_os = "macos")] return modifiers.logo(); // Use Command key on macOS #[cfg(not(target_os = "macos"))] return modifiers.control(); // Use Control key on other platforms } impl DataViewer { pub(crate) fn handle_key_pressed_event(&mut self, key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Vec> { let mut tasks = Vec::new(); match key.as_ref() { Key::Named(Named::Tab) => { debug!("Tab pressed"); self.toggle_footer(); } Key::Named(Named::Space) | Key::Character("b") => { debug!("Space pressed"); self.toggle_slider_type(); } Key::Character("h") | Key::Character("H") => { debug!("H key pressed"); // Only toggle split orientation in dual pane mode if self.pane_layout == PaneLayout::DualPane { self.toggle_split_orientation(); } } Key::Character("1") => { debug!("Key1 pressed"); if self.pane_layout == PaneLayout::DualPane && self.is_slider_dual { self.panes[0].is_selected = !self.panes[0].is_selected; } // If shift+alt is pressed, load a file into pane0 if modifiers.shift() && modifiers.alt() { debug!("Key1 Shift+Alt pressed"); tasks.push(Task::perform(file_io::pick_file(), move |result| { Message::FolderOpened(result, 0) })); } // If alt is pressed, load a folder into pane0 else if modifiers.alt() { debug!("Key1 Alt pressed"); tasks.push(Task::perform(file_io::pick_folder(), move |result| { Message::FolderOpened(result, 0) })); } // If platform_modifier is pressed, switch to single pane layout else if is_platform_modifier(&modifiers) { self.toggle_pane_layout(PaneLayout::SinglePane); } } Key::Character("2") => { debug!("Key2 pressed"); if self.pane_layout == PaneLayout::DualPane && self.is_slider_dual { self.panes[1].is_selected = !self.panes[1].is_selected; } // If shift+alt is pressed, load a file into pane1 if self.pane_layout == PaneLayout::DualPane && modifiers.shift() && modifiers.alt() { debug!("Key2 Shift+Alt pressed"); tasks.push(Task::perform(file_io::pick_file(), move |result| { Message::FolderOpened(result, 1) })); } // If alt is pressed, load a folder into pane1 else if self.pane_layout == PaneLayout::DualPane && modifiers.alt() { debug!("Key2 Alt pressed"); tasks.push(Task::perform(file_io::pick_folder(), move |result| { Message::FolderOpened(result, 1) })); } // If platform_modifier is pressed, switch to dual pane with synced slider else if is_platform_modifier(&modifiers) { debug!("Key2 Ctrl pressed"); self.toggle_pane_layout(PaneLayout::DualPane); if self.is_slider_dual { self.toggle_slider_type(); } } } Key::Character("c") | Key::Character("w") => { // Close the selected panes if is_platform_modifier(&modifiers) { self.reset_state(-1); } } Key::Character("q") => { // Terminate the app if is_platform_modifier(&modifiers) { tasks.push(Task::done(Message::SaveWindowState)); tasks.push(Task::done(Message::Quit)); } } Key::Character("s") if is_platform_modifier(&modifiers) => { { debug!("Save file with platform_modifier+s"); if self.panes[0].current_image.len() > 0 { tasks.push(Task::perform(file_io::pick_save_file(), move |result| { Message::ReadySaveImage(result) })); } } } Key::Character("o") => { // If platform_modifier is pressed, open a file or folder if is_platform_modifier(&modifiers) { let pane_index = if self.pane_layout == PaneLayout::SinglePane { 0 // Use first pane in single-pane mode } else { self.last_opened_pane as usize // Use last opened pane in dual-pane mode }; debug!("o key pressed pane_index: {}", pane_index); // If shift is pressed or we have uppercase O, open folder if modifiers.shift() { debug!("Opening folder with platform_modifier+shift+o"); tasks.push(Task::perform(file_io::pick_folder(), move |result| { Message::FolderOpened(result, pane_index) })); } else { // Otherwise open file debug!("Opening file with platform_modifier+o"); tasks.push(Task::perform(file_io::pick_file(), move |result| { Message::FolderOpened(result, pane_index) })); } } } Key::Named(Named::ArrowLeft) | Key::Character("a") => { // Check for first image navigation with platform modifier or Fn key if is_platform_modifier(&modifiers) { debug!("Navigating to first image"); self.use_slider_image_for_render = false; // Clear slider_image_position when navigating to first image for pane in self.panes.iter_mut() { pane.slider_image_position = None; } // Find which panes need to be updated let mut operations = Vec::new(); for (idx, pane) in self.panes.iter_mut().enumerate() { if pane.dir_loaded && (pane.is_selected || self.is_slider_dual) { // Navigate to the first image (index 0) if pane.img_cache.current_index > 0 { let new_pos = 0; pane.slider_value = new_pos as u16; self.slider_value = new_pos as u16; // Save the operation for later execution operations.push((idx as isize, new_pos)); } } } // Now execute all operations after the loop is complete for (pane_idx, new_pos) in operations { tasks.push(crate::navigation_slider::load_remaining_images( &self.device, &self.queue, self.is_gpu_supported, self.cache_strategy, self.compression_strategy, &mut self.panes, &mut self.loading_status, pane_idx, new_pos, )); } return tasks; } // Existing left-arrow logic if self.skate_right { self.skate_right = false; // Discard all queue items that are LoadNext or ShiftNext self.loading_status.reset_load_next_queue_items(); } if self.pane_layout == PaneLayout::DualPane && self.is_slider_dual && !self.panes.iter().any(|pane| pane.is_selected) { debug!("No panes selected"); } if self.skate_left { // will be handled at the end of update() to run move_left_all } else if modifiers.shift() { self.skate_left = true; self.use_slider_image_for_render = false; // Clear slider_image_position when entering skate mode for pane in self.panes.iter_mut() { pane.slider_image_position = None; } } else { self.skate_left = false; self.use_slider_image_for_render = false; // Clear slider_image_position when keyboard navigation starts for pane in self.panes.iter_mut() { pane.slider_image_position = None; } debug!("move_left_all from handle_key_pressed_event()"); let task = move_left_all( &self.device, &self.queue, self.cache_strategy, self.compression_strategy, &mut self.panes, &mut self.loading_status, &mut self.slider_value, &self.pane_layout, self.is_slider_dual, self.last_opened_pane as usize); tasks.push(task); } } Key::Named(Named::ArrowRight) | Key::Character("d") => { // Check for last image navigation with platform modifier or Fn key if is_platform_modifier(&modifiers) { debug!("Navigating to last image"); self.use_slider_image_for_render = false; // Clear slider_image_position when navigating to last image for pane in self.panes.iter_mut() { pane.slider_image_position = None; } // Find which panes need to be updated let mut operations = Vec::new(); for (idx, pane) in self.panes.iter_mut().enumerate() { if pane.dir_loaded && (pane.is_selected || self.is_slider_dual) { // Get the last valid index if let Some(last_index) = pane.img_cache.image_paths.len().checked_sub(1) { if pane.img_cache.current_index < last_index { let new_pos = last_index; pane.slider_value = new_pos as u16; self.slider_value = new_pos as u16; // Save the operation for later execution operations.push((idx as isize, new_pos)); } } } } // Now execute all operations after the loop is complete for (pane_idx, new_pos) in operations { tasks.push(crate::navigation_slider::load_remaining_images( &self.device, &self.queue, self.is_gpu_supported, self.cache_strategy, self.compression_strategy, &mut self.panes, &mut self.loading_status, pane_idx, new_pos, )); } return tasks; } // Existing right-arrow logic debug!("Right key or 'D' key pressed!"); if self.skate_left { self.skate_left = false; // Discard all queue items that are LoadPrevious or ShiftPrevious self.loading_status.reset_load_previous_queue_items(); } if self.pane_layout == PaneLayout::DualPane && self.is_slider_dual && !self.panes.iter().any(|pane| pane.is_selected) { debug!("No panes selected"); } if modifiers.shift() { self.skate_right = true; self.use_slider_image_for_render = false; // Clear slider_image_position when entering skate mode for pane in self.panes.iter_mut() { pane.slider_image_position = None; } } else { self.skate_right = false; self.use_slider_image_for_render = false; // Clear slider_image_position when keyboard navigation starts for pane in self.panes.iter_mut() { pane.slider_image_position = None; } let task = move_right_all( &self.device, &self.queue, self.cache_strategy, self.compression_strategy, &mut self.panes, &mut self.loading_status, &mut self.slider_value, &self.pane_layout, self.is_slider_dual, self.last_opened_pane as usize); tasks.push(task); debug!("handle_key_pressed_event() - tasks count: {}", tasks.len()); } } Key::Named(Named::F3) => { self.show_fps = !self.show_fps; debug!("Toggled debug FPS display: {}", self.show_fps); } Key::Named(Named::Super) => { #[cfg(target_os = "macos")] { self.set_ctrl_pressed(true); } } Key::Named(Named::Control) => { #[cfg(not(target_os = "macos"))] { self.set_ctrl_pressed(true); } } _ => { // Check if selection module wants to handle this key #[cfg(feature = "selection")] if let Some(task) = crate::widgets::selection_widget::handle_keyboard_event( key, modifiers, &self.pane_layout, self.last_opened_pane, ) { tasks.push(task); } // Check if COCO module wants to handle this key #[cfg(feature = "coco")] if let Some(task) = crate::coco::widget::handle_keyboard_event( key, modifiers, &self.pane_layout, self.last_opened_pane, ) { tasks.push(task); } } } tasks } pub(crate) fn handle_key_released_event(&mut self, key_code: &keyboard::Key, _modifiers: keyboard::Modifiers) -> Vec> { #[allow(unused_mut)] let mut tasks = Vec::new(); match key_code.as_ref() { Key::Named(Named::Tab) => { debug!("Tab released"); } Key::Named(Named::Enter) | Key::Character("NumpadEnter") => { debug!("Enter key released!"); } Key::Named(Named::Escape) => { debug!("Escape key released!"); } Key::Named(Named::ArrowLeft) | Key::Character("a") => { debug!("Left key or 'A' key released!"); self.skate_left = false; } Key::Named(Named::ArrowRight) | Key::Character("d") => { debug!("Right key or 'D' key released!"); self.skate_right = false; } Key::Named(Named::Super) => { #[cfg(target_os = "macos")] { self.set_ctrl_pressed(false); } } Key::Named(Named::Control) => { #[cfg(not(target_os = "macos"))] { self.set_ctrl_pressed(false); } } _ => {}, } tasks } } ================================================ FILE: src/app/message.rs ================================================ use std::path::PathBuf; use iced_core::Event; use iced_core::image::Handle; use iced_core::Color; use iced_winit::winit::dpi::{PhysicalPosition, PhysicalSize}; use crate::cache::img_cache::{CachedData, CacheStrategy, ImageMetadata, LoadOperation}; use crate::menu::PaneLayout; use crate::file_io; use iced_wgpu::engine::CompressionStrategy; /// Result of async directory enumeration #[derive(Debug, Clone)] pub struct DirectoryEnumResult { pub file_paths: Vec, pub directory_path: String, pub initial_index: usize, } /// Error type for async directory enumeration #[derive(Debug, Clone)] pub enum DirectoryEnumError { NoImagesFound, DirectoryError(String), NotFound, } /// Result type for slider image widget loading: (pane_idx, position, handle, dimensions, file_size) pub type SliderImageWidgetResult = Result<(usize, usize, Handle, (u32, u32), u64), (usize, usize)>; /// Result type for batch image loading: (cached_data, metadata, load_operation) pub type ImagesLoadedResult = Result<(Vec>, Vec>, Option), std::io::ErrorKind>; #[derive(Debug, Clone)] pub enum Message { Debug(String), Nothing, ShowAbout, HideAbout, ShowOptions, HideOptions, SaveWindowState, SaveSettings, ClearSettingsStatus, SettingsTabSelected(usize), ShowLogs, OpenSettingsDir, ExportDebugLogs, ExportAllLogs, OpenWebLink(String), // Note: Changed from font::Error to () since the error is never used #[allow(dead_code)] FontLoaded(Result<(), ()>), OpenFolder(usize), OpenFile(usize), FileDropped(isize, String), Close, Quit, ReplayKeepAlive, FolderOpened(Result, usize), DirectoryEnumerated(Result, usize), SliderChanged(isize, u16), SliderReleased(isize, u16), #[allow(dead_code)] SliderImageLoaded(Result<(usize, CachedData), usize>), SliderImageWidgetLoaded(SliderImageWidgetResult), Event(Event), ImagesLoaded(ImagesLoadedResult), OnSplitResize(u16), ResetSplit(u16), ToggleSliderType(bool), TogglePaneLayout(PaneLayout), ToggleFooter(bool), PaneSelected(usize, bool), CopyFilename(usize), CopyFilePath(usize), CopyImage(usize), #[allow(dead_code)] BackgroundColorChanged(Color), #[allow(dead_code)] TimerTick, SetCacheStrategy(CacheStrategy), SetCompressionStrategy(CompressionStrategy), ToggleFpsDisplay(bool), ToggleSplitOrientation(bool), ToggleSyncedZoom(bool), ToggleMouseWheelZoom(bool), ToggleCopyButtons(bool), ToggleMetadataDisplay(bool), ToggleNearestNeighborFilter(bool), SetSpinnerLocation(crate::settings::SpinnerLocation), #[cfg(feature = "coco")] ToggleCocoSimplification(bool), #[cfg(feature = "coco")] SetCocoMaskRenderMode(crate::settings::CocoMaskRenderMode), ToggleFullScreen(bool), CursorOnTop(bool), CursorOnMenu(bool), CursorOnFooter(bool), #[cfg(feature = "selection")] SelectionAction(crate::widgets::selection_widget::SelectionMessage), #[cfg(feature = "coco")] CocoAction(crate::coco::widget::CocoMessage), // Advanced settings input AdvancedSettingChanged(String, String), // (field_name, value) ResetAdvancedSettings, // Window resize WindowResized(f32, PhysicalSize, bool), // (new width, window size, is window maximized) PositionChanged( PhysicalPosition, Option, ), // (window position, current monitor) RequestSaveImage, ReadySaveImage(Result), HideSuccessSaveModal, HideFailureSaveModal, } ================================================ FILE: src/app/message_handlers.rs ================================================ // Comprehensive message handler module that routes different message categories // This significantly reduces the size of app.rs update() method use std::path::PathBuf; use std::sync::Arc; use log::{info, warn, error, debug}; use iced_winit::runtime::Task; use iced_wgpu::engine::CompressionStrategy; use iced_core::Event; use iced_runtime::clipboard; use crate::app::{DataViewer, Message}; use crate::cache::img_cache::{CacheStrategy, CachedData, LoadOperation}; use crate::exif_utils::decode_with_exif_orientation; use crate::settings::{UserSettings, WindowState}; use crate::utils::save::extract_gpu_image; use crate::{file_io, window_state::get_window_visible}; use crate::loading_handler; use crate::navigation_slider; use crate::navigation_keyboard::{move_left_all, move_right_all}; use crate::menu::PaneLayout; use crate::pane::{IMAGE_RENDER_TIMES, IMAGE_RENDER_FPS}; use crate::widgets::shader::{scene::Scene, cpu_scene::CpuScene}; #[allow(unused_imports)] use std::time::Instant; /// Main entry point for handling all messages /// Routes messages to appropriate handler functions pub fn handle_message(app: &mut DataViewer, message: Message) -> Task { match message { // Simple inline messages Message::Nothing => Task::none(), Message::Debug(s) => { app.title = s; Task::none() } Message::BackgroundColorChanged(color) => { app.background_color = color; Task::none() } Message::FontLoaded(_) => Task::none(), Message::TimerTick => { debug!("TimerTick received"); Task::none() } Message::Quit => { let _ = handle_save_window_state(app); std::process::exit(0); } Message::ReplayKeepAlive => { // This message is sent periodically during replay mode to keep the update loop active debug!("ReplayKeepAlive received - keeping replay update loop active"); // Reset pending flag so a new keep-alive can be scheduled app.replay_keep_alive_pending = false; Task::none() } // UI state messages (About, Options, Logs) Message::ShowLogs | Message::OpenSettingsDir | Message::ExportDebugLogs | Message::ExportAllLogs | Message::ShowAbout | Message::HideAbout | Message::ShowOptions | Message::HideOptions | Message::OpenWebLink(_) => { handle_ui_messages(app, message) } // Settings messages Message::SaveWindowState | Message::SaveSettings | Message::ClearSettingsStatus | Message::SettingsTabSelected(_) | Message::AdvancedSettingChanged(_, _) | Message::ResetAdvancedSettings => { handle_settings_messages(app, message) } // File operation messages Message::OpenFolder(_) | Message::OpenFile(_) | Message::FileDropped(_, _) | Message::Close | Message::FolderOpened(_, _) | Message::DirectoryEnumerated(_, _) | Message::CopyFilename(_) | Message::CopyFilePath(_) | Message::CopyImage(_) => { handle_file_messages(app, message) } // Image loading messages Message::ImagesLoaded(_) | Message::SliderImageWidgetLoaded(_) | Message::SliderImageLoaded(_) => { handle_image_loading_messages(app, message) } // Slider and navigation messages Message::SliderChanged(_, _) | Message::SliderReleased(_, _) => { handle_slider_messages(app, message) } Message::RequestSaveImage | Message::ReadySaveImage(_) => handle_save_image(app, message), // Toggle and UI control messages Message::OnSplitResize(_) | Message::ResetSplit(_) | Message::ToggleSliderType(_) | Message::TogglePaneLayout(_) | Message::ToggleFooter(_) | Message::ToggleSyncedZoom(_) | Message::ToggleMouseWheelZoom(_) | Message::ToggleCopyButtons(_) | Message::ToggleMetadataDisplay(_) | Message::ToggleNearestNeighborFilter(_) | Message::SetSpinnerLocation(_) | Message::ToggleFullScreen(_) | Message::ToggleFpsDisplay(_) | Message::ToggleSplitOrientation(_) | Message::CursorOnTop(_) | Message::CursorOnMenu(_) | Message::CursorOnFooter(_) | Message::PaneSelected(_, _) | Message::SetCacheStrategy(_) | Message::SetCompressionStrategy(_) | Message::WindowResized(_, _, _) | Message::PositionChanged(_, _) | Message::HideSuccessSaveModal | Message::HideFailureSaveModal => { handle_toggle_messages(app, message) } #[cfg(feature = "coco")] Message::ToggleCocoSimplification(_) => { handle_toggle_messages(app, message) } #[cfg(feature = "coco")] Message::SetCocoMaskRenderMode(_) => { handle_toggle_messages(app, message) } // Event messages (mouse, keyboard, file drops) Message::Event(event) => { handle_event_messages(app, event) } // Feature-specific messages #[cfg(feature = "selection")] Message::SelectionAction(msg) => { crate::widgets::selection_widget::handle_selection_message( msg, &app.panes, &mut app.selection_manager, ) } #[cfg(feature = "coco")] Message::CocoAction(coco_msg) => { crate::coco::widget::handle_coco_message( coco_msg, &mut app.panes, &mut app.annotation_manager, ) } } } /// Routes UI state messages (About, Options, Logs, etc.) pub fn handle_ui_messages(app: &mut DataViewer, message: Message) -> Task { match message { Message::ShowLogs => { let app_name = "viewskater"; let log_dir_path = crate::logging::get_log_directory(app_name); let _ = std::fs::create_dir_all(log_dir_path.clone()); crate::logging::open_in_file_explorer(log_dir_path.to_string_lossy().as_ref()); Task::none() } Message::OpenSettingsDir => { let settings_path = UserSettings::settings_path(); if let Some(settings_dir) = settings_path.parent() { let _ = std::fs::create_dir_all(settings_dir); crate::logging::open_in_file_explorer(settings_dir.to_string_lossy().as_ref()); } Task::none() } Message::ExportDebugLogs => { let app_name = "viewskater"; if let Some(log_buffer) = crate::get_shared_log_buffer() { crate::logging::export_and_open_debug_logs(app_name, log_buffer); } else { warn!("Log buffer not available for export"); } Task::none() } Message::ExportAllLogs => { handle_export_all_logs(); Task::none() } Message::ShowAbout => { app.show_about = true; Task::perform(async { std::thread::sleep(std::time::Duration::from_millis(5)); }, |_| Message::Nothing) } Message::HideAbout => { app.show_about = false; Task::none() } Message::ShowOptions => { app.settings.show(); Task::perform(async { std::thread::sleep(std::time::Duration::from_millis(5)); }, |_| Message::Nothing) } Message::HideOptions => { app.settings.hide(); Task::none() } Message::OpenWebLink(url) => { if let Err(e) = webbrowser::open(&url) { warn!("Failed to open link: {}, error: {:?}", url, e); } Task::none() } _ => Task::none() } } /// Routes settings-related messages pub fn handle_settings_messages(app: &mut DataViewer, message: Message) -> Task { match message { Message::SaveWindowState => handle_save_window_state(app), Message::SaveSettings => handle_save_settings(app), Message::ClearSettingsStatus => { app.settings.clear_save_status(); Task::none() } Message::SettingsTabSelected(index) => { app.settings.set_active_tab(index); Task::none() } Message::AdvancedSettingChanged(field_name, value) => { app.settings.set_advanced_input(field_name, value); Task::none() } Message::ResetAdvancedSettings => { handle_reset_advanced_settings(app); Task::none() } _ => Task::none() } } /// Routes file operation messages pub fn handle_file_messages(app: &mut DataViewer, message: Message) -> Task { match message { Message::OpenFolder(pane_index) => { Task::perform(file_io::pick_folder(), move |result| { Message::FolderOpened(result, pane_index) }) } Message::OpenFile(pane_index) => { Task::perform(file_io::pick_file(), move |result| { Message::FolderOpened(result, pane_index) }) } Message::FileDropped(pane_index, dropped_path) => { handle_file_dropped(app, pane_index, dropped_path) } Message::Close => { app.reset_state(-1); debug!("directory_path: {:?}", app.directory_path); debug!("self.current_image_index: {}", app.current_image_index); for pane in app.panes.iter_mut() { let img_cache = &mut pane.img_cache; debug!("img_cache.current_index: {}", img_cache.current_index); debug!("img_cache.image_paths.len(): {}", img_cache.image_paths.len()); } Task::none() } Message::FolderOpened(result, pane_index) => { match result { Ok(dir) => { debug!("Folder opened: {}", dir); if pane_index > 0 && app.pane_layout == PaneLayout::SinglePane { debug!("Ignoring request to open folder in pane {} while in single-pane mode", pane_index); Task::none() } else { app.initialize_dir_path(&PathBuf::from(dir), pane_index) } } Err(err) => { debug!("Folder open failed: {:?}", err); Task::none() } } } Message::DirectoryEnumerated(result, pane_index) => { use crate::app::DirectoryEnumError; match result { Ok(enum_result) => { debug!("Directory enumerated: {} images found", enum_result.file_paths.len()); app.complete_dir_initialization(enum_result, pane_index) } Err(DirectoryEnumError::NoImagesFound) => { error!("No supported images found in directory"); Task::none() } Err(DirectoryEnumError::DirectoryError(e)) => { error!("Directory enumeration error: {}", e); Task::none() } Err(DirectoryEnumError::NotFound) => { error!("Path not found"); Task::none() } } } Message::CopyFilename(pane_index) => { let path = &app.panes[pane_index].img_cache.image_paths[app.panes[pane_index].img_cache.current_index]; let filename_str = path.file_name().to_string(); if let Some(filename) = file_io::get_filename(&filename_str) { debug!("Copying filename to clipboard: {}", filename); return clipboard::write(filename); } Task::none() } Message::CopyFilePath(pane_index) => { let path = &app.panes[pane_index].img_cache.image_paths[app.panes[pane_index].img_cache.current_index]; let img_path = path.file_name().to_string(); if let Some(dir_path) = app.panes[pane_index].directory_path.as_ref() { let full_path = PathBuf::from(dir_path).join(img_path); debug!("Copying full path to clipboard: {}", full_path.display()); return clipboard::write(full_path.to_string_lossy().to_string()); } Task::none() } Message::CopyImage(pane_index) => { let cache = &app.panes[pane_index].img_cache; // Try CPU cache first, fall back to reading from disk for GPU-cached images let bytes = if let Ok(cached_data) = cache.get_current_image() { cached_data.as_vec().ok() } else { None }; let bytes = bytes.or_else(|| { let path_source = &cache.image_paths[cache.current_index]; match path_source { crate::cache::img_cache::PathSource::Filesystem(path) => { std::fs::read(path).ok() } _ => { error!("Cannot copy image: archive images require CPU cache"); None } } }); if let Some(bytes) = bytes { std::thread::spawn(move || { match image::load_from_memory(&bytes) { Ok(img) => { let rgba = img.to_rgba8(); let (w, h) = rgba.dimensions(); let img_data = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Owned(rgba.into_raw()), }; match arboard::Clipboard::new() { Ok(mut clip) => { if let Err(e) = clip.set_image(img_data) { error!("Failed to copy image to clipboard: {}", e); } else { debug!("Image copied to clipboard ({}x{})", w, h); } } Err(e) => error!("Failed to open clipboard: {}", e), } } Err(e) => error!("Failed to decode image for clipboard: {}", e), } }); } Task::none() } _ => Task::none() } } /// Routes image loading messages pub fn handle_image_loading_messages(app: &mut DataViewer, message: Message) -> Task { match message { Message::ImagesLoaded(result) => { debug!("ImagesLoaded"); match result { Ok((image_data, metadata, operation)) => { if let Some(op) = operation { let cloned_op = op.clone(); match op { LoadOperation::LoadNext((ref pane_indices, ref target_indices)) | LoadOperation::LoadPrevious((ref pane_indices, ref target_indices)) | LoadOperation::ShiftNext((ref pane_indices, ref target_indices)) | LoadOperation::ShiftPrevious((ref pane_indices, ref target_indices)) => { let operation_type = cloned_op.operation_type(); loading_handler::handle_load_operation_all( &mut app.panes, &mut app.loading_status, pane_indices, target_indices, &image_data, &metadata, &cloned_op, operation_type, ); // Clear loading timer for the panes that completed // (clear per-pane, not based on global queue state) for &pane_idx in pane_indices { if let Some(pane) = app.panes.get_mut(pane_idx) { pane.loading_started_at = None; } } } LoadOperation::LoadPos((pane_index, target_indices_and_cache)) => { loading_handler::handle_load_pos_operation( &mut app.panes, &mut app.loading_status, pane_index, &target_indices_and_cache, &image_data, &metadata, ); // Clear loading timer for this pane if let Some(pane) = app.panes.get_mut(pane_index) { pane.loading_started_at = None; } // Signal replay controller that initial load is complete if let Some(ref mut replay_controller) = app.replay_controller { if matches!(replay_controller.state, crate::replay::ReplayState::WaitingForReady { .. }) { debug!("LoadPos complete - signaling replay controller that app is ready to navigate"); // Set image count for slider mode navigation if let Some(pane) = app.panes.get(pane_index) { replay_controller.set_image_count(pane.img_cache.image_paths.len()); } // Reset FPS trackers right before navigation starts // This ensures no stale data from image loading contaminates metrics if let Ok(mut fps) = crate::CURRENT_FPS.lock() { *fps = 0.0; } if let Ok(mut fps) = IMAGE_RENDER_FPS.lock() { *fps = 0.0; } if let Ok(mut times) = crate::FRAME_TIMES.lock() { times.clear(); } if let Ok(mut times) = IMAGE_RENDER_TIMES.lock() { times.clear(); } iced_wgpu::reset_image_fps(); replay_controller.on_ready_to_navigate(); } } } } } } Err(err) => { debug!("Image load failed: {:?}", err); } } Task::none() } Message::SliderImageWidgetLoaded(result) => { match result { Ok((pane_idx, pos, handle, dimensions, file_size)) => { crate::track_async_delivery(); if let Some(pane) = app.panes.get_mut(pane_idx) { pane.slider_image = Some(handle); pane.slider_image_dimensions = Some(dimensions); pane.slider_image_position = Some(pos); // Update metadata for footer display during slider dragging pane.current_image_metadata = Some(crate::cache::img_cache::ImageMetadata::new( dimensions.0, dimensions.1, file_size )); // BUGFIX: Don't update current_index here! It causes desyncs when stale slider images // load after slider release. The slider position is tracked in slider_image_position instead. // pane.img_cache.current_index = pos; debug!("Slider image loaded for pane {} at position {} with dimensions {:?}", pane_idx, pos, dimensions); } else { warn!("SliderImageWidgetLoaded: Invalid pane index {}", pane_idx); } }, Err((pane_idx, pos)) => { warn!("SLIDER: Failed to load image widget for pane {} at position {}", pane_idx, pos); } } Task::none() } Message::SliderImageLoaded(result) => { match result { Ok((pos, cached_data)) => { let pane = &mut app.panes[0]; if let CachedData::Cpu(bytes) = &cached_data { debug!("SliderImageLoaded: loaded data: {:?}", bytes.len()); pane.current_image = CachedData::Cpu(bytes.clone()); pane.current_image_index = Some(pos); pane.slider_scene = Some(Scene::CpuScene(CpuScene::new( bytes.clone(), true))); if let Some(device) = &pane.device { if let Some(queue) = &pane.queue { if let Some(scene) = &mut pane.slider_scene { scene.ensure_texture(device, queue, pane.pane_id); } } } } }, Err(pos) => { warn!("SLIDER: Failed to load image for position {}", pos); } } Task::none() } _ => Task::none() } } /// Routes slider and navigation messages pub fn handle_slider_messages(app: &mut DataViewer, message: Message) -> Task { match message { Message::SliderChanged(pane_index, value) => { app.is_slider_moving = true; app.use_slider_image_for_render = true; app.last_slider_update = Instant::now(); // Reset COCO zoom state when slider starts moving #[cfg(feature = "coco")] { if pane_index == -1 { // Reset all panes for pane in app.panes.iter_mut() { pane.zoom_scale = 1.0; pane.zoom_offset = iced_core::Vector::default(); } } else { // Reset specific pane if let Some(pane) = app.panes.get_mut(pane_index as usize) { pane.zoom_scale = 1.0; pane.zoom_offset = iced_core::Vector::default(); } } } let use_async = true; #[cfg(target_os = "linux")] let use_throttle = true; #[cfg(not(target_os = "linux"))] let use_throttle = false; if pane_index == -1 { app.prev_slider_value = app.slider_value; app.slider_value = value; if app.panes[0].slider_image.is_none() { for pane in app.panes.iter_mut() { pane.slider_scene = None; } } } else { let pane_index_usize = pane_index as usize; if app.is_slider_dual && app.pane_layout == PaneLayout::DualPane { for idx in 0..app.panes.len() { if idx != pane_index_usize { app.panes[idx].slider_image = None; app.panes[idx].slider_image_position = None; } } } let pane = &mut app.panes[pane_index_usize]; pane.prev_slider_value = pane.slider_value; pane.slider_value = value; if pane.slider_image.is_none() { pane.slider_scene = None; } } navigation_slider::update_pos( &mut app.panes, pane_index, value as usize, use_async, use_throttle, ) } Message::SliderReleased(pane_index, value) => { debug!("SLIDER_DEBUG: SliderReleased event received"); app.is_slider_moving = false; let final_image_fps = iced_wgpu::get_image_fps(); let upload_timestamps = iced_wgpu::get_image_upload_timestamps(); if !upload_timestamps.is_empty() { if let Ok(mut render_times) = IMAGE_RENDER_TIMES.lock() { *render_times = upload_timestamps.into_iter().collect(); if let Ok(mut fps) = IMAGE_RENDER_FPS.lock() { *fps = final_image_fps as f32; debug!("SLIDER_DEBUG: Synced image fps tracking, final FPS: {:.1}", final_image_fps); } } } // Use the position of the currently displayed slider_image if available, // otherwise fall back to the slider value let pos = if pane_index >= 0 { app.panes.get(pane_index as usize) .and_then(|pane| pane.slider_image_position) .unwrap_or(value as usize) } else { // For pane_index == -1 (all panes), use slider_image_position from pane 0 app.panes.first() .and_then(|pane| pane.slider_image_position) .unwrap_or(value as usize) }; debug!("SliderReleased: Using position {} (slider_image_position) instead of slider value {}", pos, value); navigation_slider::load_remaining_images( &app.device, &app.queue, app.is_gpu_supported, app.cache_strategy, app.compression_strategy, &mut app.panes, &mut app.loading_status, pane_index, pos) } _ => Task::none() } } /// Routes toggle and UI control messages pub fn handle_toggle_messages(app: &mut DataViewer, message: Message) -> Task { match message { Message::OnSplitResize(position) => { app.divider_position = Some(position); Task::none() } Message::ResetSplit(_position) => { app.divider_position = None; Task::none() } Message::ToggleSliderType(_bool) => { app.toggle_slider_type(); Task::none() } Message::TogglePaneLayout(pane_layout) => { app.toggle_pane_layout(pane_layout); Task::none() } Message::ToggleFooter(_bool) => { app.toggle_footer(); Task::none() } Message::ToggleSyncedZoom(enabled) => { app.synced_zoom = enabled; Task::none() } Message::ToggleMouseWheelZoom(enabled) => { app.mouse_wheel_zoom = enabled; for pane in app.panes.iter_mut() { pane.mouse_wheel_zoom = enabled; } Task::none() } Message::ToggleCopyButtons(enabled) => { app.show_copy_buttons = enabled; Task::none() } Message::ToggleMetadataDisplay(enabled) => { app.show_metadata = enabled; Task::none() } Message::HideSuccessSaveModal => { app.toggle_success_save_modal(); Task::none() } Message::HideFailureSaveModal => { app.set_failure_save_modal(None); Task::none() } Message::ToggleNearestNeighborFilter(enabled) => { debug!("ToggleNearestNeighborFilter: setting to {}", enabled); app.nearest_neighbor_filter = enabled; // Force reload of current directories to apply the new filter immediately let mut tasks = Vec::new(); for pane_index in 0..app.panes.len() { if let Some(dir_path) = app.panes[pane_index].directory_path.clone() { debug!("Reloading directory for pane {}: {:?}", pane_index, dir_path); tasks.push(app.initialize_dir_path(&PathBuf::from(dir_path), pane_index)); } } Task::batch(tasks) } Message::SetSpinnerLocation(location) => { debug!("SetSpinnerLocation: setting to {:?}", location); app.spinner_location = location; Task::none() } #[cfg(feature = "coco")] Message::ToggleCocoSimplification(enabled) => { app.coco_disable_simplification = enabled; Task::none() } #[cfg(feature = "coco")] Message::SetCocoMaskRenderMode(mode) => { app.coco_mask_render_mode = mode; Task::none() } Message::ToggleFullScreen(enabled) => { if enabled { app.window_state = WindowState::FullScreen; } else { app.window_state = WindowState::Window; } Task::none() } Message::ToggleFpsDisplay(value) => { app.show_fps = value; Task::none() } Message::ToggleSplitOrientation(_bool) => { app.toggle_split_orientation(); Task::none() } Message::CursorOnTop(value) => { app.cursor_on_top = value; Task::none() } Message::CursorOnMenu(value) => { app.cursor_on_menu = value; Task::none() } Message::CursorOnFooter(value) => { app.cursor_on_footer = value; Task::none() } Message::PaneSelected(pane_index, is_selected) => { app.panes[pane_index].is_selected = is_selected; for (index, pane) in app.panes.iter_mut().enumerate() { debug!("pane_index: {}, is_selected: {}", index, pane.is_selected); } Task::none() } Message::SetCacheStrategy(strategy) => { app.update_cache_strategy(strategy); Task::none() } Message::SetCompressionStrategy(strategy) => { app.update_compression_strategy(strategy); Task::none() } Message::WindowResized(width, size, is_maximized) => { app.window_width = width; app.window_size = size; // Track the largest size seen while maximized (used by Linux X11 un-maximize workaround) if is_maximized { let should_update = app.maximized_size.map_or(true, |max_size| { size.width > max_size.width || size.height > max_size.height }); if should_update { app.maximized_size = Some(size); } } // macOS: use is_maximized (winit's isZoomed() wrapper), same as other platforms. // isZoomed() may be unreliable mid-animation, but save_window_state_to_disk // queries it authoritatively post-animation as a safety net. #[cfg(target_os = "macos")] match app.window_state { WindowState::Window => { if is_maximized { app.window_state = WindowState::Maximized; app.last_windowed_position = app.position_before_transition; } }, WindowState::Maximized => { if !is_maximized { app.window_state = WindowState::Window; } }, _ => {}, } // Windows/Linux: use winit's is_maximized() (reliable on these platforms) #[cfg(not(target_os = "macos"))] match app.window_state { WindowState::Window => { if is_maximized { app.window_state = WindowState::Maximized; // Windows workaround: PositionChanged(0,0) fires before this event, // corrupting last_windowed_position. Restore from backup. app.last_windowed_position = app.position_before_transition; } }, WindowState::Maximized => { if !is_maximized { // Primary detection: is_maximized() returned false (works on Windows/macOS) app.window_state = WindowState::Window; } else { // X11 workaround: is_maximized() returns stale true during un-maximize transition // On X11, maximized windows cannot be resized - any size change means un-maximize #[cfg(target_os = "linux")] { let size_changed = app.maximized_size.map_or(false, |max_size| size != max_size); if size_changed { app.window_state = WindowState::Window; app.maximized_size = None; } } } }, _ => {}, } Task::none() } Message::PositionChanged(position, monitor) => { app.window_position = position; let is_same_monitor = app.last_monitor == monitor; // Only track last_windowed_position when in windowed state or moving across different monitors // Save previous value first (Windows workaround: PositionChanged fires before WindowResized // during maximize, so we need to be able to restore if transition is detected) if app.window_state == WindowState::Window || !is_same_monitor { app.position_before_transition = app.last_windowed_position; app.last_windowed_position = position; app.last_monitor = monitor; } Task::none() } _ => Task::none() } } /// Routes event messages (mouse wheel, keyboard, file drops) pub fn handle_event_messages(app: &mut DataViewer, event: Event) -> Task { match event { Event::Mouse(iced_core::mouse::Event::WheelScrolled { delta }) => { if !app.ctrl_pressed && !app.mouse_wheel_zoom && !app.settings.is_visible() && !app.show_about { match delta { iced_core::mouse::ScrollDelta::Lines { y, .. } | iced_core::mouse::ScrollDelta::Pixels { y, .. } => { if y > 0.0 { // Clear slider state when using mouse wheel navigation app.use_slider_image_for_render = false; for pane in app.panes.iter_mut() { pane.slider_image_position = None; } return move_left_all( &app.device, &app.queue, app.cache_strategy, app.compression_strategy, &mut app.panes, &mut app.loading_status, &mut app.slider_value, &app.pane_layout, app.is_slider_dual, app.last_opened_pane as usize); } else if y < 0.0 { // Clear slider state when using mouse wheel navigation app.use_slider_image_for_render = false; for pane in app.panes.iter_mut() { pane.slider_image_position = None; } return move_right_all( &app.device, &app.queue, app.cache_strategy, app.compression_strategy, &mut app.panes, &mut app.loading_status, &mut app.slider_value, &app.pane_layout, app.is_slider_dual, app.last_opened_pane as usize ); } } }; } else { // Mouse wheel with ctrl pressed or mouse_wheel_zoom enabled = zoom mode // Clear slider state to switch to ImageShader widget which handles zoom if app.use_slider_image_for_render { app.use_slider_image_for_render = false; for pane in app.panes.iter_mut() { pane.slider_image_position = None; } } } Task::none() } Event::Keyboard(iced_core::keyboard::Event::KeyPressed { key, modifiers, .. }) => { debug!("KeyPressed - Key pressed: {:?}, modifiers: {:?}", key, modifiers); debug!("modifiers.shift(): {}", modifiers.shift()); let tasks = app.handle_key_pressed_event(&key, modifiers); if !tasks.is_empty() { return Task::batch(tasks); } Task::none() } Event::Keyboard(iced_core::keyboard::Event::KeyReleased { key, modifiers, .. }) => { let tasks = app.handle_key_released_event(&key, modifiers); if !tasks.is_empty() { return Task::batch(tasks); } Task::none() } #[cfg(any(target_os = "macos", target_os = "windows"))] Event::Window(iced_core::window::Event::FileDropped(dropped_paths, _position)) => { handle_window_file_drop(app, &dropped_paths[0]) } #[cfg(target_os = "linux")] Event::Window(iced_core::window::Event::FileDropped(dropped_path, _)) => { handle_window_file_drop(app, &dropped_path[0]) } _ => Task::none() } } // ============================================================================ // Helper functions // ============================================================================ fn handle_window_file_drop(app: &mut DataViewer, path: &std::path::Path) -> Task { if app.pane_layout != PaneLayout::SinglePane { return Task::none(); } // Check if it's a JSON file that might be COCO format #[cfg(feature = "coco")] if path.extension().and_then(|s| s.to_str()) == Some("json") { debug!("JSON file detected in window event, checking if it's COCO format: {}", path.display()); match std::fs::read_to_string(path) { Ok(content) => { if crate::coco::parser::CocoDataset::is_coco_format(&content) { info!("✓ Detected COCO JSON file: {}", path.display()); return Task::done(Message::CocoAction( crate::coco::widget::CocoMessage::LoadCocoFile(path.to_path_buf()) )); } else { debug!("JSON file is not COCO format, treating as regular file"); } } Err(e) => { warn!("Failed to read JSON file: {}", e); } } } app.reset_state(-1); debug!("File dropped: {:?}", path); app.initialize_dir_path(&path.to_path_buf(), 0) } fn handle_file_dropped(app: &mut DataViewer, pane_index: isize, dropped_path: String) -> Task { let path = PathBuf::from(&dropped_path); #[cfg(feature = "coco")] debug!("COCO FEATURE IS ENABLED"); #[cfg(not(feature = "coco"))] debug!("COCO FEATURE IS DISABLED"); #[cfg(feature = "coco")] if path.extension().and_then(|s| s.to_str()) == Some("json") { debug!("JSON file detected, checking if it's COCO format: {}", path.display()); match std::fs::read_to_string(&path) { Ok(content) => { if crate::coco::parser::CocoDataset::is_coco_format(&content) { info!("✓ Detected COCO JSON file: {}", path.display()); return Task::none(); } else { debug!("JSON file is not COCO format, treating as regular file"); } } Err(e) => { warn!("Failed to read JSON file: {}", e); } } } debug!("Message::FileDropped - Resetting state"); app.reset_state(pane_index); debug!("File dropped: {:?}, pane_index: {}", dropped_path, pane_index); debug!("self.dir_loaded, pane_index, last_opened_pane: {:?}, {}, {}", app.panes[pane_index as usize].dir_loaded, pane_index, app.last_opened_pane); app.initialize_dir_path(&path, pane_index as usize) } fn handle_save_settings(app: &mut DataViewer) -> Task { let parse_value = |key: &str, _default: u64| -> Result { app.settings.advanced_input .get(key) .ok_or_else(|| format!("Missing value for {}", key))? .parse::() .map_err(|_| format!("Invalid number for {}", key)) }; let cache_size = match parse_value("cache_size", 5) { Ok(v) if v > 0 && v <= 100 => v as usize, Ok(_) => { app.settings.set_save_status(Some("Error: Cache size must be between 1 and 100".to_string())); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } Err(e) => { app.settings.set_save_status(Some(format!("Error parsing cache_size: {}", e))); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } }; let max_loading_queue_size = match parse_value("max_loading_queue_size", 3) { Ok(v) if v > 0 && v <= 50 => v as usize, Ok(_) => { app.settings.set_save_status(Some("Error: Max loading queue size must be between 1 and 50".to_string())); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } Err(e) => { app.settings.set_save_status(Some(format!("Error parsing max_loading_queue_size: {}", e))); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } }; let max_being_loaded_queue_size = match parse_value("max_being_loaded_queue_size", 3) { Ok(v) if v > 0 && v <= 50 => v as usize, Ok(_) => { app.settings.set_save_status(Some("Error: Max being loaded queue size must be between 1 and 50".to_string())); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } Err(e) => { app.settings.set_save_status(Some(format!("Error parsing max_being_loaded_queue_size: {}", e))); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } }; let atlas_size = match parse_value("atlas_size", 2048) { Ok(v) if (256..=8192).contains(&v) && v.is_power_of_two() => v as u32, Ok(_) => { app.settings.set_save_status(Some("Error: Atlas size must be a power of 2 between 256 and 8192".to_string())); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } Err(e) => { app.settings.set_save_status(Some(format!("Error parsing atlas_size: {}", e))); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } }; let double_click_threshold_ms = match parse_value("double_click_threshold_ms", 250) { Ok(v) if (50..=1000).contains(&v) => v as u16, Ok(_) => { app.settings.set_save_status(Some("Error: Double-click threshold must be between 50 and 1000 ms".to_string())); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } Err(e) => { app.settings.set_save_status(Some(format!("Error parsing double_click_threshold_ms: {}", e))); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } }; let archive_cache_size = match parse_value("archive_cache_size", 200) { Ok(v) if (10..=10000).contains(&v) => v, Ok(_) => { app.settings.set_save_status(Some("Error: Archive cache size must be between 10 and 10000 MB".to_string())); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } Err(e) => { app.settings.set_save_status(Some(format!("Error parsing archive_cache_size: {}", e))); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } }; let archive_warning_threshold_mb = match parse_value("archive_warning_threshold_mb", 500) { Ok(v) if (10..=10000).contains(&v) => v, Ok(_) => { app.settings.set_save_status(Some("Error: Archive warning threshold must be between 10 and 10000 MB".to_string())); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } Err(e) => { app.settings.set_save_status(Some(format!("Error parsing archive_warning_threshold_mb: {}", e))); return Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus); } }; let settings = UserSettings { show_fps: app.show_fps, show_footer: app.show_footer, is_horizontal_split: app.is_horizontal_split, synced_zoom: app.synced_zoom, mouse_wheel_zoom: app.mouse_wheel_zoom, show_copy_buttons: app.show_copy_buttons, show_metadata: app.show_metadata, nearest_neighbor_filter: app.nearest_neighbor_filter, cache_strategy: match app.cache_strategy { CacheStrategy::Cpu => "cpu".to_string(), CacheStrategy::Gpu => "gpu".to_string(), }, compression_strategy: match app.compression_strategy { CompressionStrategy::None => "none".to_string(), CompressionStrategy::Bc1 => "bc1".to_string(), }, is_slider_dual: app.is_slider_dual, cache_size, max_loading_queue_size, max_being_loaded_queue_size, window_width: app.window_size.width, window_height: app.window_size.height, atlas_size, double_click_threshold_ms, archive_cache_size, archive_warning_threshold_mb, #[cfg(feature = "coco")] coco_disable_simplification: app.coco_disable_simplification, #[cfg(not(feature = "coco"))] coco_disable_simplification: false, #[cfg(feature = "coco")] coco_mask_render_mode: app.coco_mask_render_mode, #[cfg(not(feature = "coco"))] coco_mask_render_mode: crate::settings::CocoMaskRenderMode::default(), use_binary_size: app.use_binary_size, spinner_location: app.spinner_location, window_state: app.window_state, window_position_x: app.window_position.x, window_position_y: app.window_position.y, }; let old_settings = UserSettings::load(None); let window_settings_changed = atlas_size != old_settings.atlas_size; match settings.save() { Ok(_) => { info!("Settings saved successfully"); app.archive_cache_size = archive_cache_size * 1_048_576; app.archive_warning_threshold_mb = archive_warning_threshold_mb; info!("Archive settings applied immediately: cache_size={}MB, warning_threshold={}MB", archive_cache_size, archive_warning_threshold_mb); if cache_size != app.cache_size { info!("Cache size changed from {} to {}, reloading all panes", app.cache_size, cache_size); app.cache_size = cache_size; let pane_file_lengths: Vec = app.panes.iter() .map(|p| p.img_cache.num_files) .collect(); let cache_size = app.cache_size; let archive_cache_size = app.archive_cache_size; let archive_warning_threshold_mb = app.archive_warning_threshold_mb; for (i, pane) in app.panes.iter_mut().enumerate() { if let Some(dir_path) = &pane.directory_path.clone() { if pane.dir_loaded { let path = PathBuf::from(dir_path); let _ = pane.initialize_dir_path( &Arc::clone(&app.device), &Arc::clone(&app.queue), app.is_gpu_supported, app.cache_strategy, app.compression_strategy, &app.pane_layout, &pane_file_lengths, i, &path, app.is_slider_dual, &mut app.slider_value, cache_size, archive_cache_size, archive_warning_threshold_mb, ); } } } } if max_loading_queue_size != app.max_loading_queue_size || max_being_loaded_queue_size != app.max_being_loaded_queue_size { info!("Queue size settings changed: max_loading_queue_size={}, max_being_loaded_queue_size={}", max_loading_queue_size, max_being_loaded_queue_size); app.max_loading_queue_size = max_loading_queue_size; app.max_being_loaded_queue_size = max_being_loaded_queue_size; for pane in app.panes.iter_mut() { pane.max_loading_queue_size = max_loading_queue_size; pane.max_being_loaded_queue_size = max_being_loaded_queue_size; } } if double_click_threshold_ms != app.double_click_threshold_ms { info!("Double-click threshold changed from {} to {} ms", app.double_click_threshold_ms, double_click_threshold_ms); app.double_click_threshold_ms = double_click_threshold_ms; } app.settings.set_save_status(Some(if window_settings_changed { "Settings saved! Window settings require restart, other changes applied immediately.".to_string() } else { "Settings saved! All changes applied immediately.".to_string() })); Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus) } Err(e) => { error!("Failed to save settings: {}", e); app.settings.set_save_status(Some(format!("Error: {}", e))); Task::perform(async { tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; }, |_| Message::ClearSettingsStatus) } } } fn handle_save_window_state(app: &mut DataViewer) -> Task { let mut old_settings = UserSettings::load(None); let tuple = get_window_visible(app.last_windowed_position, app.window_size, app.last_monitor.clone()); // Prevents the saved position from being outside of the monitor if !tuple.0 { app.last_windowed_position = tuple.1; } // Use last_windowed_position to avoid saving maximized position (0,0) on Windows old_settings.window_position_x = app.last_windowed_position.x; old_settings.window_position_y = app.last_windowed_position.y; if app.window_state == WindowState::Window { old_settings.window_width = app.window_size.width; old_settings.window_height = app.window_size.height; } old_settings.window_state = app.window_state; if let Err(e) = old_settings.save() { error!("Failed to save window state: {e}"); } Task::none() } fn handle_reset_advanced_settings(app: &mut DataViewer) { use crate::config; app.show_fps = false; app.show_footer = true; app.is_horizontal_split = false; app.synced_zoom = true; app.mouse_wheel_zoom = false; app.cache_strategy = CacheStrategy::Gpu; app.compression_strategy = CompressionStrategy::None; app.is_slider_dual = false; app.settings.advanced_input.insert("cache_size".to_string(), config::DEFAULT_CACHE_SIZE.to_string()); app.settings.advanced_input.insert("max_loading_queue_size".to_string(), config::DEFAULT_MAX_LOADING_QUEUE_SIZE.to_string()); app.settings.advanced_input.insert("max_being_loaded_queue_size".to_string(), config::DEFAULT_MAX_BEING_LOADED_QUEUE_SIZE.to_string()); app.settings.advanced_input.insert("window_width".to_string(), config::DEFAULT_WINDOW_WIDTH.to_string()); app.settings.advanced_input.insert("window_height".to_string(), config::DEFAULT_WINDOW_HEIGHT.to_string()); app.settings.advanced_input.insert("atlas_size".to_string(), config::DEFAULT_ATLAS_SIZE.to_string()); app.settings.advanced_input.insert("double_click_threshold_ms".to_string(), config::DEFAULT_DOUBLE_CLICK_THRESHOLD_MS.to_string()); app.settings.advanced_input.insert("archive_cache_size".to_string(), config::DEFAULT_ARCHIVE_CACHE_SIZE.to_string()); app.settings.advanced_input.insert("archive_warning_threshold_mb".to_string(), config::DEFAULT_ARCHIVE_WARNING_THRESHOLD_MB.to_string()); } fn handle_export_all_logs() { println!("DEBUG: ExportAllLogs message received"); let app_name = "viewskater"; if let Some(log_buffer) = crate::get_shared_log_buffer() { println!("DEBUG: Got log buffer, starting export..."); if let Some(stdout_buffer) = crate::get_shared_stdout_buffer() { println!("DEBUG: Got stdout buffer, calling export_and_open_all_logs..."); crate::logging::export_and_open_all_logs(app_name, log_buffer, stdout_buffer); println!("DEBUG: export_and_open_all_logs completed"); } else { println!("DEBUG: Stdout buffer not available, exporting debug logs only"); match crate::logging::export_debug_logs(app_name, log_buffer) { Ok(debug_log_path) => { println!("DEBUG: Export successful to: {}", debug_log_path.display()); info!("Debug logs successfully exported to: {}", debug_log_path.display()); } Err(e) => { println!("DEBUG: Export failed: {}", e); error!("Failed to export debug logs: {}", e); eprintln!("Failed to export debug logs: {}", e); } } } println!("DEBUG: Export operation completed"); } else { println!("DEBUG: Log buffer not available"); warn!("Log buffer not available for export"); } println!("DEBUG: ExportAllLogs handler finished"); } pub fn handle_save_image(app: &mut DataViewer, message: Message) -> Task { let current_image = &app.panes.first().as_ref().unwrap().current_image; if current_image.len() == 0 { Task::none() } else { match message { Message::ReadySaveImage(result) => { match result { Ok(path) => { let format = path .extension() .and_then(image::ImageFormat::from_extension); if let Some(format) = format { let (width, height) = current_image.dimensions(); let save_result = match current_image { CachedData::Cpu(items) => { match decode_with_exif_orientation(items) { Ok(image) => image.save_with_format(path, format), Err(e) => Err(std::io::Error::from(e).into()), } }, CachedData::Gpu(texture) => { let texture = texture.clone(); let buf= extract_gpu_image(app, &texture); match format { image::ImageFormat::Jpeg => { let rgb: Vec = buf .chunks_exact(4) .flat_map(|p| [p[0], p[1], p[2]]) .collect(); image::save_buffer_with_format(&path, &rgb, width, height, image::ColorType::Rgb8, format) } _ => { image::save_buffer_with_format(&path, &buf, width, height, image::ColorType::Rgba8, format) } } } CachedData::BC1(_texture) => { app.set_failure_save_modal(Some("BC1 Saving is currently unsupported".into())); return Task::none() }, }; match save_result { Ok(_) => app.toggle_success_save_modal(), Err(e) => app.set_failure_save_modal(Some(e.to_string())), } Task::none() } else { app.set_failure_save_modal(Some( "Wrong file extension, cannot determine format".into(), )); Task::none() } } Err(err) => { if let file_io::Error::InvalidExtension = err { app.set_failure_save_modal(Some("Error selecting save file - invalid extension".into())); } debug!("Save file select error: {:?}", err); Task::none() } } } Message::RequestSaveImage => Task::perform(file_io::pick_save_file(), move |result| { Message::ReadySaveImage(result) }), _ => Task::none(), } } } ================================================ FILE: src/app/replay_handlers.rs ================================================ //! Replay mode integration for DataViewer //! //! Handles replay controller updates and action processing for automated benchmarking. use std::time::Duration; use iced_winit::runtime::Task; use log::{debug, info, warn}; use super::{DataViewer, Message}; /// Reset all FPS counters and timing history for fresh measurements fn reset_fps_trackers() { if let Ok(mut fps) = crate::CURRENT_FPS.lock() { *fps = 0.0; } if let Ok(mut fps) = crate::pane::IMAGE_RENDER_FPS.lock() { *fps = 0.0; } if let Ok(mut times) = crate::FRAME_TIMES.lock() { times.clear(); } if let Ok(mut times) = crate::pane::IMAGE_RENDER_TIMES.lock() { times.clear(); } iced_wgpu::reset_image_fps(); } impl DataViewer { /// Update replay mode logic and return any action that should be processed pub(crate) fn update_replay_mode(&mut self) -> Option { let replay_controller = self.replay_controller.as_mut()?; if !replay_controller.is_active() && !replay_controller.is_completed() && !replay_controller.config.test_directories.is_empty() { // Start replay if we have a controller but it's not active yet and not completed replay_controller.start(); // Get the first directory for loading return replay_controller.get_current_directory().map(|dir| { crate::replay::ReplayAction::LoadDirectory(dir.clone()) }); } if !replay_controller.is_active() { return None; } debug!("App update called during active replay mode, state: {:?}", replay_controller.state); // Update metrics with current FPS and memory values let ui_fps = crate::CURRENT_FPS.lock().map(|fps| *fps).unwrap_or(0.0); let image_fps = if self.is_slider_moving { iced_wgpu::get_image_fps() as f32 } else { crate::pane::IMAGE_RENDER_FPS.lock().map(|fps| *fps).unwrap_or(0.0) }; let memory_mb = crate::CURRENT_MEMORY_USAGE.lock() .map(|mem| if *mem == u64::MAX { -1.0 } else { *mem as f64 / 1024.0 / 1024.0 }) .unwrap_or(0.0); replay_controller.update_metrics(ui_fps, image_fps, memory_mb); // Extract state info before mutable operations (to satisfy borrow checker) let (state_type, start_time_elapsed) = match &replay_controller.state { crate::replay::ReplayState::NavigatingRight { start_time, .. } => ("right", Some(start_time.elapsed())), crate::replay::ReplayState::NavigatingLeft { start_time, .. } => ("left", Some(start_time.elapsed())), _ => ("other", None), }; let duration_limit = replay_controller.config.duration_per_directory + Duration::from_secs(1); // Synchronize app navigation state with replay controller state // In slider mode, boundary detection is handled by the replay controller (position tracking) // In keyboard mode, we sync skate flags with app state let is_slider_mode = replay_controller.config.navigation_mode == crate::replay::NavigationMode::Slider; // Keyboard mode only: sync skate flags with app state // Slider mode handles boundary detection via position tracking in replay controller if !is_slider_mode { match state_type { "right" => { let at_end = self.panes.iter().any(|pane| { pane.is_selected && pane.dir_loaded && pane.img_cache.current_index >= pane.img_cache.image_paths.len().saturating_sub(1) }); replay_controller.set_at_boundary(at_end); if at_end && self.skate_right { debug!("Reached end of images, stopping right navigation"); self.skate_right = false; } else if !at_end && !self.skate_right { debug!("Syncing app state: setting skate_right = true"); self.skate_right = true; self.skate_left = false; } if let Some(elapsed) = start_time_elapsed { if elapsed > duration_limit { warn!("Replay seems stuck in NavigatingRight state, forcing progress"); self.skate_right = false; } } } "left" => { let at_beginning = self.panes.iter().any(|pane| { pane.is_selected && pane.dir_loaded && pane.img_cache.current_index == 0 }); replay_controller.set_at_boundary(at_beginning); if at_beginning && self.skate_left { debug!("Reached beginning of images, stopping left navigation"); self.skate_left = false; } else if !at_beginning && !self.skate_left { debug!("Syncing app state: setting skate_left = true"); self.skate_left = true; self.skate_right = false; } if let Some(elapsed) = start_time_elapsed { if elapsed > duration_limit { warn!("Replay seems stuck in NavigatingLeft state, forcing progress"); self.skate_left = false; } } } _ => { if self.skate_right || self.skate_left { debug!("Syncing app state: clearing navigation flags"); self.skate_right = false; self.skate_left = false; } } } } // Get action from replay controller let action = replay_controller.update(); if let Some(ref a) = action { debug!("Replay controller returned action: {:?}", a); } // Schedule keep-alive task if replay is active and we don't already have one in flight // This prevents accumulating many delayed messages when update() is called rapidly // Use navigation_interval to ensure we poll fast enough for the desired speed if replay_controller.is_active() && !self.replay_keep_alive_pending { let interval_ms = replay_controller.config.navigation_interval.as_millis() as u64; self.replay_keep_alive_task = Some(Task::perform( async move { tokio::time::sleep(tokio::time::Duration::from_millis(interval_ms)).await; }, |_| Message::ReplayKeepAlive )); } action } /// Process a replay action and return the appropriate task pub(crate) fn process_replay_action(&mut self, action: crate::replay::ReplayAction) -> Option> { match action { crate::replay::ReplayAction::LoadDirectory(path) => { info!("Loading directory for replay: {}", path.display()); self.reset_state(-1); reset_fps_trackers(); // Initialize directory and get the image loading task let load_task = self.initialize_dir_path(&path, 0); // Notify replay controller that directory loading started // on_ready_to_navigate() will be called when ImagesLoaded (LoadPos) completes if let Some(ref mut replay_controller) = self.replay_controller { if let Some(directory_index) = replay_controller.config.test_directories.iter().position(|p| p == &path) { replay_controller.on_directory_loaded(directory_index); } } // Return the load task so images actually get loaded Some(load_task) } crate::replay::ReplayAction::RestartIteration(path) => { // Restart iteration by fully reloading the first directory // This ensures pane state is properly reset to the beginning info!("Restarting iteration - loading directory: {}", path.display()); self.reset_state(-1); reset_fps_trackers(); // Initialize directory and get the image loading task let load_task = self.initialize_dir_path(&path, 0); // Notify replay controller that directory loading started if let Some(ref mut replay_controller) = self.replay_controller { if let Some(directory_index) = replay_controller.config.test_directories.iter().position(|p| p == &path) { replay_controller.on_directory_loaded(directory_index); } } Some(load_task) } crate::replay::ReplayAction::NavigateRight => { self.skate_right = true; if let Some(ref mut replay_controller) = self.replay_controller { replay_controller.on_navigation_performed(); } None } crate::replay::ReplayAction::NavigateLeft => { self.skate_left = true; if let Some(ref mut replay_controller) = self.replay_controller { replay_controller.on_navigation_performed(); } None } crate::replay::ReplayAction::StartNavigatingLeft => { reset_fps_trackers(); self.skate_right = false; self.skate_left = true; if let Some(ref mut replay_controller) = self.replay_controller { replay_controller.on_navigation_performed(); } None } crate::replay::ReplayAction::SliderNavigate { position } => { // Slider mode navigation: send SliderChanged message to simulate slider drag debug!("Slider navigate to position {}", position); // Use pane index -1 to affect the selected pane (same as global slider) Some(Task::done(Message::SliderChanged(-1, position))) } crate::replay::ReplayAction::SliderStartNavigatingLeft => { reset_fps_trackers(); // Slider mode doesn't use skate flags - position tracking is in replay controller None } crate::replay::ReplayAction::Finish => { info!("Replay mode finished"); if let Some(ref controller) = self.replay_controller { if controller.config.auto_exit { info!("Auto-exit enabled, exiting application"); std::process::exit(0); } } None } } } } ================================================ FILE: src/app/settings_widget.rs ================================================ //! Settings widget module //! Manages all settings-related state and UI for the application use std::collections::HashMap; use crate::settings::UserSettings; /// Runtime-configurable settings that can be applied immediately without restart #[derive(Debug, Clone)] pub struct RuntimeSettings { pub mouse_wheel_zoom: bool, // Flag to change mouse scroll wheel behavior pub show_copy_buttons: bool, // Show copy filename/filepath buttons in footer pub show_metadata: bool, // Show image metadata (resolution, file size) in footer pub cache_size: usize, // Image cache window size (number of images to cache) pub archive_cache_size: u64, // Archive cache size in bytes (for preload decision) pub archive_warning_threshold_mb: u64, // Warning threshold for large solid archives (MB) pub max_loading_queue_size: usize, // Max size for loading queue pub max_being_loaded_queue_size: usize, // Max size for being loaded queue pub double_click_threshold_ms: u16, // Double-click threshold in milliseconds } impl RuntimeSettings { pub fn from_user_settings(settings: &UserSettings) -> Self { Self { mouse_wheel_zoom: settings.mouse_wheel_zoom, show_copy_buttons: settings.show_copy_buttons, show_metadata: settings.show_metadata, cache_size: settings.cache_size, archive_cache_size: settings.archive_cache_size * 1_048_576, // Convert MB to bytes archive_warning_threshold_mb: settings.archive_warning_threshold_mb, max_loading_queue_size: settings.max_loading_queue_size, max_being_loaded_queue_size: settings.max_being_loaded_queue_size, double_click_threshold_ms: settings.double_click_threshold_ms, } } } /// Settings widget state pub struct SettingsWidget { pub show_options: bool, // Settings modal visibility pub save_status: Option, // Save feedback message pub active_tab: usize, // Which tab is selected pub advanced_input: HashMap, // Text input state for advanced settings pub runtime_settings: RuntimeSettings, // Runtime-configurable settings } impl SettingsWidget { pub fn new(settings: &UserSettings) -> Self { // Initialize advanced settings input with current values let mut advanced_input = HashMap::new(); advanced_input.insert("cache_size".to_string(), settings.cache_size.to_string()); advanced_input.insert("max_loading_queue_size".to_string(), settings.max_loading_queue_size.to_string()); advanced_input.insert("max_being_loaded_queue_size".to_string(), settings.max_being_loaded_queue_size.to_string()); advanced_input.insert("window_width".to_string(), settings.window_width.to_string()); advanced_input.insert("window_height".to_string(), settings.window_height.to_string()); advanced_input.insert("atlas_size".to_string(), settings.atlas_size.to_string()); advanced_input.insert("double_click_threshold_ms".to_string(), settings.double_click_threshold_ms.to_string()); advanced_input.insert("archive_cache_size".to_string(), settings.archive_cache_size.to_string()); advanced_input.insert("archive_warning_threshold_mb".to_string(), settings.archive_warning_threshold_mb.to_string()); Self { show_options: false, save_status: None, active_tab: 0, advanced_input, runtime_settings: RuntimeSettings::from_user_settings(settings), } } pub fn show(&mut self) { self.show_options = true; } pub fn hide(&mut self) { self.show_options = false; } pub fn set_save_status(&mut self, status: Option) { self.save_status = status; } pub fn clear_save_status(&mut self) { self.save_status = None; } pub fn set_active_tab(&mut self, tab: usize) { self.active_tab = tab; } pub fn set_advanced_input(&mut self, key: String, value: String) { self.advanced_input.insert(key, value); } pub fn is_visible(&self) -> bool { self.show_options } } ================================================ FILE: src/app.rs ================================================ // Submodules mod message; mod message_handlers; mod keyboard_handlers; mod replay_handlers; mod settings_widget; use iced_core::Length; use iced_core::alignment::Horizontal; // Re-exports pub use message::{Message, DirectoryEnumResult, DirectoryEnumError}; pub use settings_widget::{RuntimeSettings, SettingsWidget}; #[warn(unused_imports)] #[cfg(target_os = "linux")] mod other_os { pub use iced_custom as iced; } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; } use iced_winit::winit::dpi::{ PhysicalPosition, PhysicalSize }; #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use once_cell::sync::Lazy; #[allow(unused_imports)] use std::time::{Duration, Instant}; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; use iced::{ widget::button, font::Font, Task, }; use iced_widget::{row, column, container, text}; use iced_wgpu::{wgpu, Renderer}; use iced_wgpu::engine::CompressionStrategy; use iced_winit::core::Theme as WinitTheme; use iced_winit::core::{Color, Element}; use crate::navigation_keyboard::{move_right_all, move_left_all}; use crate::cache::img_cache::CacheStrategy; use crate::menu::PaneLayout; use crate::pane::{self, Pane}; use crate::settings::WindowState; use crate::ui; use crate::widgets; use crate::loading_status; use crate::utils::timing::TimingStats; use crate::RendererRequest; use crate::build_info::BuildInfo; #[cfg(feature = "selection")] use crate::selection_manager::SelectionManager; use crate::settings::UserSettings; use crate::widgets::modal; use std::sync::mpsc::{Sender, Receiver}; #[allow(dead_code)] static APP_UPDATE_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("App Update")) }); pub struct DataViewer { pub background_color: Color,//debug pub title: String, pub directory_path: Option, pub current_image_index: usize, pub slider_value: u16, // for master slider pub prev_slider_value: u16, // for master slider pub divider_position: Option, pub is_slider_dual: bool, pub show_footer: bool, pub pane_layout: PaneLayout, pub last_opened_pane: isize, pub panes: Vec, // Each pane has its own image cache pub loading_status: loading_status::LoadingStatus, // global loading status for all panes pub skate_right: bool, pub skate_left: bool, pub update_counter: u32, pub show_about: bool, pub settings: SettingsWidget, // Settings widget (modal, tabs, runtime settings) pub device: Arc, // Shared ownership using Arc pub queue: Arc, // Shared ownership using Arc pub is_gpu_supported: bool, pub cache_strategy: CacheStrategy, pub last_slider_update: Instant, pub is_slider_moving: bool, pub use_slider_image_for_render: bool, // Keep using Viewer widget after slider release until keyboard nav pub backend: wgpu::Backend, pub show_fps: bool, pub compression_strategy: CompressionStrategy, pub renderer_request_sender: Sender, pub is_horizontal_split: bool, pub file_receiver: Receiver, pub synced_zoom: bool, pub nearest_neighbor_filter: bool, pub replay_controller: Option, pub replay_keep_alive_task: Option>, pub replay_keep_alive_pending: bool, // Track if a keep-alive is in flight to prevent flooding pub window_state: WindowState, pub cursor_on_top: bool, pub cursor_on_menu: bool, // Flag to show menu when fullscreen pub cursor_on_footer: bool, // Flag to show footer when fullscreen pub(crate) ctrl_pressed: bool, // Flag to save ctrl/cmd(macOS) press state pub use_binary_size: bool, // Use binary (KiB/MiB) vs decimal (KB/MB) for file sizes pub spinner_location: crate::settings::SpinnerLocation, // Where to show loading spinner pub window_width: f32, // Current window width for responsive layout #[cfg(feature = "selection")] pub selection_manager: SelectionManager, // Manages image selections/exclusions #[cfg(feature = "coco")] pub annotation_manager: crate::coco::annotation_manager::AnnotationManager, // Manages COCO annotations #[cfg(feature = "coco")] pub coco_disable_simplification: bool, // COCO: Disable polygon simplification for RLE masks #[cfg(feature = "coco")] pub coco_mask_render_mode: crate::settings::CocoMaskRenderMode, // COCO: Mask rendering mode (Polygon or Pixel) pub window_size: PhysicalSize, pub maximized_size: Option>, // Tracks size when maximized (for X11 un-maximize detection) pub window_position: PhysicalPosition, pub last_windowed_position: PhysicalPosition, // Tracks position when in windowed mode pub position_before_transition: PhysicalPosition, // Backup for Windows maximize fix pub last_monitor: Option, // Track position when not in windowed mode with multiple monitors pub show_success_save_modal: bool, pub show_failure_save_modal: Option, } // Implement Deref to expose RuntimeSettings fields directly on DataViewer impl std::ops::Deref for DataViewer { type Target = RuntimeSettings; fn deref(&self) -> &Self::Target { &self.settings.runtime_settings } } // Implement DerefMut to allow mutable access to RuntimeSettings fields impl std::ops::DerefMut for DataViewer { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.settings.runtime_settings } } impl DataViewer { pub fn new( device: Arc, queue: Arc, backend: wgpu::Backend, renderer_request_sender: Sender, file_receiver: Receiver, settings_path: Option<&str>, replay_config: Option, ) -> Self { // Load user settings from YAML file let settings = UserSettings::load(settings_path); let cache_strategy = settings.get_cache_strategy(); let compression_strategy = settings.get_compression_strategy(); info!("Initializing DataViewer with settings:"); info!(" show_fps: {}", settings.show_fps); info!(" show_footer: {}", settings.show_footer); info!(" is_horizontal_split: {}", settings.is_horizontal_split); info!(" synced_zoom: {}", settings.synced_zoom); info!(" mouse_wheel_zoom: {}", settings.mouse_wheel_zoom); info!(" show_copy_buttons: {}", settings.show_copy_buttons); info!(" nearest_neighbor_filter: {}", settings.nearest_neighbor_filter); info!(" cache_strategy: {:?}", cache_strategy); info!(" compression_strategy: {:?}", compression_strategy); info!(" is_slider_dual: {}", settings.is_slider_dual); Self { title: String::from("ViewSkater"), directory_path: None, current_image_index: 0, slider_value: 0, prev_slider_value: 0, divider_position: None, is_slider_dual: settings.is_slider_dual, show_footer: settings.show_footer, pane_layout: PaneLayout::SinglePane, last_opened_pane: -1, panes: vec![pane::Pane::new(Arc::clone(&device), Arc::clone(&queue), backend, 0, compression_strategy)], loading_status: loading_status::LoadingStatus::default(), skate_right: false, skate_left: false, update_counter: 0, show_about: false, settings: SettingsWidget::new(&settings), device, queue, is_gpu_supported: true, background_color: Color::WHITE, last_slider_update: Instant::now(), is_slider_moving: false, use_slider_image_for_render: false, backend, cache_strategy, show_fps: settings.show_fps, compression_strategy, renderer_request_sender, is_horizontal_split: settings.is_horizontal_split, file_receiver, synced_zoom: settings.synced_zoom, nearest_neighbor_filter: settings.nearest_neighbor_filter, replay_controller: replay_config.map(crate::replay::ReplayController::new), replay_keep_alive_task: None, replay_keep_alive_pending: false, window_state: crate::config::CONFIG.window_state, cursor_on_top: false, cursor_on_menu: false, cursor_on_footer: false, ctrl_pressed: false, use_binary_size: settings.use_binary_size, spinner_location: settings.spinner_location, window_width: settings.window_width as f32, #[cfg(feature = "selection")] selection_manager: SelectionManager::new(), #[cfg(feature = "coco")] annotation_manager: crate::coco::annotation_manager::AnnotationManager::new(), #[cfg(feature = "coco")] coco_disable_simplification: settings.coco_disable_simplification, #[cfg(feature = "coco")] coco_mask_render_mode: settings.coco_mask_render_mode, window_position: PhysicalPosition { x: crate::config::CONFIG.window_position_x, y: crate::config::CONFIG.window_position_y }, last_windowed_position: PhysicalPosition { x: crate::config::CONFIG.window_position_x, y: crate::config::CONFIG.window_position_y }, position_before_transition: PhysicalPosition { x: crate::config::CONFIG.window_position_x, y: crate::config::CONFIG.window_position_y }, window_size: PhysicalSize { width: settings.window_width, height: settings.window_height }, maximized_size: None, last_monitor: None, show_success_save_modal: false, show_failure_save_modal: None, } } pub fn clear_primitive_storage(&self) { if let Err(e) = self.renderer_request_sender.send(RendererRequest::ClearPrimitiveStorage) { error!("Failed to send ClearPrimitiveStorage request: {:?}", e); } } /// Ensure panes vector has at least `pane_index + 1` panes, creating new ones as needed fn ensure_pane_exists(&mut self, pane_index: usize) { while self.panes.len() <= pane_index { let new_pane_id = self.panes.len(); debug!("Creating new pane at index {}", new_pane_id); self.panes.push(pane::Pane::new( Arc::clone(&self.device), Arc::clone(&self.queue), self.backend, new_pane_id, self.compression_strategy )); } } /// Clear cached slider images from all panes to prevent displaying stale images fn clear_slider_images(&mut self) { for pane in self.panes.iter_mut() { pane.slider_image = None; pane.slider_image_position = None; pane.slider_scene = None; } } /// Start loading neighbor images after directory initialization completes /// Sets last_opened_pane, loads selection state, and kicks off async neighbor loading fn start_neighbor_loading(&mut self, pane_index: usize) -> Task { self.last_opened_pane = pane_index as isize; #[cfg(feature = "selection")] if let Some(dir_path) = &self.panes[pane_index].directory_path { if let Err(e) = self.selection_manager.load_for_directory(dir_path) { warn!("Failed to load selection state for {}: {}", dir_path, e); } } // Set loading timer for spinner display during neighbor loading // The first image is already displayed, now we load the rest in background if let Some(pane) = self.panes.get_mut(pane_index) { pane.loading_started_at = Some(std::time::Instant::now()); debug!("SPINNER: Set loading_started_at for neighbor loading (pane {})", pane_index); } let current_index = self.panes[pane_index].img_cache.current_index; let load_task = crate::navigation_slider::load_initial_neighbors( &self.device, &self.queue, self.is_gpu_supported, self.cache_strategy, self.compression_strategy, &mut self.panes, &mut self.loading_status, pane_index, current_index, ); load_task } pub fn reset_state(&mut self, pane_index: isize) { // Reset loading status self.loading_status = loading_status::LoadingStatus::default(); if pane_index == -1 { // Reset all panes for pane in &mut self.panes { pane.reset_state(); } // Reset app-level viewer state only when resetting all panes self.title = String::from("ViewSkater"); self.directory_path = None; self.current_image_index = 0; self.slider_value = 0; self.prev_slider_value = 0; self.last_opened_pane = 0; self.skate_right = false; self.update_counter = 0; self.show_about = false; self.last_slider_update = Instant::now(); self.is_slider_moving = false; self.use_slider_image_for_render = false; // Clear primitive storage self.clear_primitive_storage(); } else { // Reset only the specified pane self.panes[pane_index as usize].reset_state(); } crate::utils::mem::log_memory("DataViewer::reset_state: After reset_state"); } pub(crate) fn initialize_dir_path(&mut self, path: &PathBuf, pane_index: usize) -> Task { debug!("last_opened_pane: {}", self.last_opened_pane); // Check if this is a compressed file - use sync path for archives if path.extension().is_some_and(|ex| { crate::file_io::ALLOWED_COMPRESSED_FILES.contains(&ex.to_ascii_lowercase().to_str().unwrap_or("")) }) { return self.initialize_dir_path_sync(path, pane_index); } self.ensure_pane_exists(pane_index); self.reset_state(pane_index as isize); self.panes[pane_index].slider_image = None; self.panes[pane_index].slider_image_position = None; self.panes[pane_index].slider_scene = None; // Dispatch async directory enumeration (Issue #73 - NFS performance fix) // Note: Loading spinner will be shown during neighbor loading phase (after first image displays) let path_clone = path.clone(); Task::perform( crate::file_io::enumerate_directory_async(path_clone), move |result| Message::DirectoryEnumerated(result, pane_index) ) } /// Sync initialization path for compressed files (zip/rar/7z) /// Archives are typically local so sync loading is acceptable fn initialize_dir_path_sync(&mut self, path: &PathBuf, pane_index: usize) -> Task { debug!("Sync initialization for compressed file: {}", path.display()); self.ensure_pane_exists(pane_index); self.clear_slider_images(); let pane_file_lengths = self.panes.iter().map( |pane| pane.img_cache.image_paths.len()).collect::>(); let cache_size = self.cache_size; let archive_cache_size = self.archive_cache_size; let archive_warning_threshold_mb = self.archive_warning_threshold_mb; let pane = &mut self.panes[pane_index]; debug!("pane_file_lengths: {:?}", pane_file_lengths); // Load first image synchronously (archives are local, so this is fast) let _ = pane.initialize_dir_path( &Arc::clone(&self.device), &Arc::clone(&self.queue), self.is_gpu_supported, self.cache_strategy, self.compression_strategy, &self.pane_layout, &pane_file_lengths, pane_index, path, self.is_slider_dual, &mut self.slider_value, cache_size, archive_cache_size, archive_warning_threshold_mb, ); // start_neighbor_loading will set loading timer for neighbor loading phase self.start_neighbor_loading(pane_index) } /// Complete directory initialization after async enumeration /// Called when DirectoryEnumerated message arrives pub(crate) fn complete_dir_initialization( &mut self, result: crate::app::message::DirectoryEnumResult, pane_index: usize, ) -> Task { debug!("Completing directory initialization: {} images found", result.file_paths.len()); let pane_file_lengths = self.panes.iter().map( |pane| pane.img_cache.image_paths.len()).collect::>(); let cache_size = self.cache_size; let pane = &mut self.panes[pane_index]; // Initialize pane with pre-enumerated paths (loads first image synchronously) pane.initialize_with_paths( &Arc::clone(&self.device), &Arc::clone(&self.queue), self.is_gpu_supported, self.cache_strategy, self.compression_strategy, &self.pane_layout, &pane_file_lengths, pane_index, result.file_paths, result.directory_path, result.initial_index, self.is_slider_dual, &mut self.slider_value, cache_size, ); // start_neighbor_loading will set loading timer for neighbor loading phase debug!("SPINNER: complete_dir_initialization calling start_neighbor_loading for pane {}", pane_index); self.start_neighbor_loading(pane_index) } fn set_ctrl_pressed(&mut self, enabled: bool) { self.ctrl_pressed = enabled; for pane in self.panes.iter_mut() { pane.ctrl_pressed = enabled; } } pub(crate) fn toggle_success_save_modal(&mut self) { self.show_success_save_modal = !self.show_success_save_modal; } pub(crate) fn set_failure_save_modal(&mut self, error_message: Option) { self.show_failure_save_modal = error_message; } fn save_result_modal( title: &str, detail: Option, on_dismiss: Message, ) -> container::Container<'_, Message, WinitTheme, Renderer> { let mut col = column![ text(title.to_owned()).size(25).font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Bold, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), ].spacing(15).align_x(Horizontal::Center).width(Length::Fill); if let Some(detail) = detail { col = col.push( text(detail) .size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color), } }), ); } col = col.push(button(text("OK")).on_press(on_dismiss)); container(col) .width(300) .padding(20) .style(|theme: &WinitTheme| iced_widget::container::Style { background: Some(theme.extended_palette().background.base.color.into()), text_color: Some(theme.extended_palette().primary.weak.text), border: iced_winit::core::Border { color: theme.extended_palette().background.strong.color, width: 1.0, radius: iced_winit::core::border::Radius::from(8.0), }, ..Default::default() }) } pub(crate) fn toggle_slider_type(&mut self) { // When toggling from dual to single, reset pane.is_selected to true if self.is_slider_dual { for pane in self.panes.iter_mut() { pane.is_selected_cache = pane.is_selected; pane.is_selected = true; pane.is_next_image_loaded = false; pane.is_prev_image_loaded = false; } let panes_refs: Vec<&mut pane::Pane> = self.panes.iter_mut().collect(); self.slider_value = pane::get_master_slider_value(&panes_refs, &self.pane_layout, self.is_slider_dual, self.last_opened_pane as usize) as u16; } else { // Single to dual slider: give slider.value to each slider for pane in self.panes.iter_mut() { pane.slider_value = pane.img_cache.current_index as u16; pane.is_selected = pane.is_selected_cache; } } self.is_slider_dual = !self.is_slider_dual; } pub(crate) fn toggle_pane_layout(&mut self, pane_layout: PaneLayout) { match pane_layout { PaneLayout::SinglePane => { Pane::resize_panes(&mut self.panes, 1); debug!("self.panes.len(): {}", self.panes.len()); if self.pane_layout == PaneLayout::DualPane { // Reset the slider value to the first pane's current index let panes_refs: Vec<&mut pane::Pane> = self.panes.iter_mut().collect(); self.slider_value = pane::get_master_slider_value(&panes_refs, &pane_layout, self.is_slider_dual, self.last_opened_pane as usize) as u16; self.panes[0].is_selected = true; } } PaneLayout::DualPane => { Pane::resize_panes(&mut self.panes, 2); debug!("self.panes.len(): {}", self.panes.len()); } } self.pane_layout = pane_layout; } pub(crate) fn toggle_footer(&mut self) { self.show_footer = !self.show_footer; } pub fn title(&self) -> String { match self.pane_layout { PaneLayout::SinglePane => { if self.panes[0].dir_loaded { let path = &self.panes[0].img_cache.image_paths[self.panes[0].img_cache.current_index]; path.file_name().to_string() } else { self.title.clone() } } PaneLayout::DualPane => { // Select labels based on split orientation let (first_label, second_label) = if self.is_horizontal_split { ("Top", "Bottom") } else { ("Left", "Right") }; let first_pane_filename = if self.panes[0].dir_loaded { let path = &self.panes[0].img_cache.image_paths[self.panes[0].img_cache.current_index]; path.file_name().to_string() } else { String::from("No File") }; let second_pane_filename = if self.panes[1].dir_loaded { let path = &self.panes[1].img_cache.image_paths[self.panes[1].img_cache.current_index]; path.file_name().to_string() } else { String::from("No File") }; format!("{}: {} | {}: {}", first_label, first_pane_filename, second_label, second_pane_filename) } } } /// Returns true if any pane has active loading (for animation loop) /// This returns true as soon as loading starts, to keep the redraw loop active pub fn is_any_pane_loading(&self) -> bool { self.panes.iter().any(|pane| pane.loading_started_at.is_some()) } pub(crate) fn update_cache_strategy(&mut self, strategy: CacheStrategy) { debug!("Changing cache strategy from {:?} to {:?}", self.cache_strategy, strategy); self.cache_strategy = strategy; // Get current pane file lengths let pane_file_lengths: Vec = self.panes.iter() .map(|p| p.img_cache.num_files) .collect(); // Capture runtime settings before mutable borrow let cache_size = self.cache_size; let archive_cache_size = self.archive_cache_size; let archive_warning_threshold_mb = self.archive_warning_threshold_mb; // Reinitialize all loaded panes with the new cache strategy for (i, pane) in self.panes.iter_mut().enumerate() { if let Some(dir_path) = &pane.directory_path.clone() { if pane.dir_loaded { let path = PathBuf::from(dir_path); // Reinitialize the pane with the current directory let _ = pane.initialize_dir_path( &Arc::clone(&self.device), &Arc::clone(&self.queue), self.is_gpu_supported, self.cache_strategy, self.compression_strategy, &self.pane_layout, &pane_file_lengths, i, &path, self.is_slider_dual, &mut self.slider_value, cache_size, archive_cache_size, archive_warning_threshold_mb, ); } } } } pub(crate) fn update_compression_strategy(&mut self, strategy: CompressionStrategy) { if self.compression_strategy != strategy { self.compression_strategy = strategy; debug!("Queuing compression strategy change to {:?}", strategy); // Instead of trying to lock renderer directly, send a request to the main thread if let Err(e) = self.renderer_request_sender.send( RendererRequest::UpdateCompressionStrategy(strategy) ) { error!("Failed to queue compression strategy change: {:?}", e); } else { debug!("Compression strategy change request sent successfully"); // Get current pane file lengths let pane_file_lengths: Vec = self.panes.iter() .map(|p| p.img_cache.num_files) .collect(); // Capture runtime settings before mutable borrow let cache_size = self.cache_size; let archive_cache_size = self.archive_cache_size; let archive_warning_threshold_mb = self.archive_warning_threshold_mb; // Recreate image cache for (i, pane) in self.panes.iter_mut().enumerate() { if let Some(dir_path) = &pane.directory_path.clone() { if pane.dir_loaded { let path = PathBuf::from(dir_path); // Reinitialize the pane with the current directory let _ = pane.initialize_dir_path( &Arc::clone(&self.device), &Arc::clone(&self.queue), self.is_gpu_supported, self.cache_strategy, self.compression_strategy, &self.pane_layout, &pane_file_lengths, i, &path, self.is_slider_dual, &mut self.slider_value, cache_size, archive_cache_size, archive_warning_threshold_mb, ); } } } } } } pub(crate) fn toggle_split_orientation(&mut self) { self.is_horizontal_split = !self.is_horizontal_split; } } impl iced_winit::runtime::Program for DataViewer { type Theme = WinitTheme; type Message = Message; type Renderer = Renderer; fn update(&mut self, message: Message) -> iced_winit::runtime::Task { // Check for any file paths received from the background thread let mut cli_tasks: Vec> = Vec::new(); while let Ok(path) = self.file_receiver.try_recv() { println!("Processing file path in main thread: {}", path); // Reset state and initialize the directory path self.reset_state(-1); println!("State reset complete, initializing directory path"); let init_task = self.initialize_dir_path(&PathBuf::from(path), 0); cli_tasks.push(init_task); println!("Directory path initialization task queued"); } let _update_start = Instant::now(); // Route message to handler let task = message_handlers::handle_message(self, message); // Handle replay mode logic if let Some(replay_action) = self.update_replay_mode() { if let Some(replay_task) = self.process_replay_action(replay_action) { return replay_task; } } // Check if we have a keep-alive task to return (for replay mode timing) let keep_alive_task = self.replay_keep_alive_task.take(); // Return the task if it's not skate mode // Skate mode overrides normal task handling for continuous navigation if self.skate_right { self.update_counter = 0; let nav_task = move_right_all( &self.device, &self.queue, self.cache_strategy, self.compression_strategy, &mut self.panes, &mut self.loading_status, &mut self.slider_value, &self.pane_layout, self.is_slider_dual, self.last_opened_pane as usize ); // Batch with keep-alive task if present (for replay mode timing) if let Some(keep_alive) = keep_alive_task { self.replay_keep_alive_pending = true; Task::batch([nav_task, keep_alive]) } else { nav_task } } else if self.skate_left { self.update_counter = 0; debug!("move_left_all from self.skate_left block"); let nav_task = move_left_all( &self.device, &self.queue, self.cache_strategy, self.compression_strategy, &mut self.panes, &mut self.loading_status, &mut self.slider_value, &self.pane_layout, self.is_slider_dual, self.last_opened_pane as usize ); // Batch with keep-alive task if present (for replay mode timing) if let Some(keep_alive) = keep_alive_task { self.replay_keep_alive_pending = true; Task::batch([nav_task, keep_alive]) } else { nav_task } } else if keep_alive_task.is_some() { // Batch keep-alive task if present (for replay mode timing) let mut batch_tasks = cli_tasks; if let Some(keep_alive) = keep_alive_task { self.replay_keep_alive_pending = true; batch_tasks.push(keep_alive); } if batch_tasks.is_empty() { task } else { batch_tasks.push(task); Task::batch(batch_tasks) } } else { // No skate mode, return the task from message handler if self.update_counter == 0 { debug!("No skate mode detected, update_counter: {}", self.update_counter); self.update_counter += 1; } if !cli_tasks.is_empty() { cli_tasks.push(task); Task::batch(cli_tasks) } else { task } } } fn view(&self) -> Element<'_, Message, WinitTheme, Renderer> { let content = ui::build_ui(self); if self.show_success_save_modal { let modal_content = Self::save_result_modal("File saved", None, Message::HideSuccessSaveModal); modal::modal(content, modal_content, Message::HideSuccessSaveModal) } else if let Some(ref error_message) = self.show_failure_save_modal { let modal_content = Self::save_result_modal("Error saving file", Some(format!("Message: {error_message}")), Message::HideFailureSaveModal); modal::modal(content, modal_content, Message::HideFailureSaveModal) } else if self.settings.is_visible() { let options_content = crate::settings_modal::view_settings_modal(self); widgets::modal::modal(content, options_content, Message::HideOptions) } else if self.show_about { // Build the info column dynamically to avoid empty text widgets let mut info_column = column![ text(format!("Version {}", BuildInfo::display_version())).size(15), text(format!("Build: {} ({})", BuildInfo::build_string(), BuildInfo::build_profile())).size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color) } }), text(format!("Commit: {}", BuildInfo::git_hash_short())).size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color) } }), text(format!("Platform: {}", BuildInfo::target_platform())).size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color), } }), text(format!("Features: {}", BuildInfo::enabled_features())).size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color), } }), ]; // Add bundle version only on macOS to avoid empty widgets let bundle_info = BuildInfo::bundle_version_display(); if !bundle_info.is_empty() { info_column = info_column.push( text(format!("Bundle: {}", bundle_info)).size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color), } }) ); } info_column = info_column.push(row![ text("Author: ").size(15), text("Gota Gando").size(15) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().primary.strong.color), } }) ]); info_column = info_column.push(text("Learn more at:").size(15)); info_column = info_column.push(button( text("https://github.com/ggand0/viewskater") .size(18) ) .style(|theme: &WinitTheme, _status| { iced_widget::button::Style { background: Some(iced_winit::core::Color::TRANSPARENT.into()), text_color: theme.extended_palette().primary.strong.color, border: iced_winit::core::Border { color: iced_winit::core::Color::TRANSPARENT, width: 1.0, radius: iced_winit::core::border::Radius::new(0.0), }, ..Default::default() } }) .on_press(Message::OpenWebLink( "https://github.com/ggand0/viewskater".to_string(), ))); info_column = info_column.spacing(4); let about_content = container( column![ text("ViewSkater").size(25) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Bold, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), info_column ] .spacing(15) .align_x(iced_winit::core::alignment::Horizontal::Center), ) .padding(20) .style(|theme: &WinitTheme| { iced_widget::container::Style { background: Some(theme.extended_palette().background.base.color.into()), text_color: Some(theme.extended_palette().primary.weak.text), border: iced_winit::core::Border { color: theme.extended_palette().background.strong.color, width: 1.0, radius: iced_winit::core::border::Radius::from(8.0), }, ..Default::default() } }); widgets::modal::modal(content, about_content, Message::HideAbout) } else { content.into() } } } ================================================ FILE: src/archive_cache.rs ================================================ use std::path::PathBuf; use std::sync::Arc; use std::io::Read; use std::collections::HashMap; #[allow(unused_imports)] use log::{debug, error, warn}; #[derive(Debug, Clone)] pub enum ArchiveType { Zip, Rar, SevenZ, } /// Archive cache that stores reusable archive instances per pane pub struct ArchiveCache { /// Current compressed file being accessed current_archive: Option<(PathBuf, ArchiveType)>, /// Cached ZIP archive instance to avoid reopening the file zip_archive: Option>>>>, /// Cached 7z archive instance sevenz_archive: Option>>>, /// Preloaded file data for small solid archives (filename -> bytes) preloaded_data: HashMap>, } impl ArchiveCache { pub fn new() -> Self { Self { current_archive: None, zip_archive: None, sevenz_archive: None, preloaded_data: HashMap::new(), } } /// Set the current archive that this cache is working with /// Clears existing cache if switching to a different archive file pub fn set_current_archive(&mut self, path: PathBuf, archive_type: ArchiveType) { // Clear cache if switching to a different archive if let Some((current_path, _)) = &self.current_archive { if *current_path != path { debug!("Switching archives, clearing cache: {:?} -> {:?}", current_path, path); self.clear_cache(); } } self.current_archive = Some((path, archive_type)); } /// Clear all cached archive instances pub fn clear_cache(&mut self) { self.zip_archive = None; self.sevenz_archive = None; self.preloaded_data.clear(); debug!("Archive cache cleared"); } /// Add preloaded data for a file (used for solid 7z preloading) pub fn add_preloaded_data(&mut self, filename: String, data: Vec) { self.preloaded_data.insert(filename, data); } /// Get preloaded data for a file if available pub fn get_preloaded_data(&self, filename: &str) -> Option<&[u8]> { self.preloaded_data.get(filename).map(|v| v.as_slice()) } /// Clear all preloaded data pub fn clear_preloaded_data(&mut self) { self.preloaded_data.clear(); } /// Read a file from the current compressed archive /// This is the main entry point for archive-only operations pub fn read_from_archive(&mut self, filename: &str) -> Result, Box> { let (path, archive_type) = match self.current_archive.as_ref() { Some((p, t)) => (p.clone(), t.clone()), None => return Err("No current archive set".into()), }; match archive_type { ArchiveType::Zip => self.read_zip_file(&path, filename), ArchiveType::Rar => self.read_rar_file(&path, filename), ArchiveType::SevenZ => self.read_7z_file(&path, filename), } } /// Read a file from ZIP archive using cached ZipArchive instance fn read_zip_file(&mut self, path: &PathBuf, filename: &str) -> Result, Box> { // Get or create cached ZIP archive if self.zip_archive.is_none() { debug!("Creating new ZIP archive instance for {:?}", path); let file = std::io::BufReader::new(std::fs::File::open(path)?); let zip_archive = zip::ZipArchive::new(file)?; self.zip_archive = Some(Arc::new(std::sync::Mutex::new(zip_archive))); } // Read from cached archive let zip_arc = self.zip_archive.as_ref().unwrap(); let mut zip = zip_arc.lock().unwrap(); let mut buffer = Vec::new(); zip.by_name(filename)?.read_to_end(&mut buffer)?; debug!("Read {} bytes from ZIP file: {}", buffer.len(), filename); Ok(buffer) } /// Read a file from RAR archive using simple filename comparison /// Uses the contributor's straightforward approach - simple and intuitive fn read_rar_file(&mut self, path: &PathBuf, filename: &str) -> Result, Box> { let mut archive = unrar::Archive::new(path).open_for_processing()?; let buffer = Vec::new(); while let Some(header) = archive.read_header()? { let entry_filename = header.entry().filename.as_os_str(); // NOTE: Printing this in the while loop is very slow //debug!("reading rar {} ?= {:?}", filename, entry_filename); archive = if filename == entry_filename { let (data, rest) = header.read()?; drop(rest); debug!("Read {} bytes from RAR file: {}", data.len(), filename); return Ok(data); } else { header.skip()? }; } Ok(buffer) } /// Read a file from 7z archive using cached ArchiveReader instance fn read_7z_file(&mut self, path: &PathBuf, filename: &str) -> Result, Box> { // Get or create cached 7z archive if self.sevenz_archive.is_none() { debug!("Creating new 7z archive instance for {:?}", path); let reader = sevenz_rust2::ArchiveReader::open(path, sevenz_rust2::Password::empty())?; self.sevenz_archive = Some(Arc::new(std::sync::Mutex::new(reader))); } // Read from cached archive let sevenz_arc = self.sevenz_archive.as_ref() .ok_or("7z archive not initialized")?; let data = match sevenz_arc.lock() { Ok(mut sevenz) => sevenz.read_file(filename)?, Err(e) => { error!("Failed to lock 7z archive: {}", e); return Err("Failed to lock 7z archive".into()); } }; debug!("Read {} bytes from 7z file: {}", data.len(), filename); Ok(data) } } impl Default for ArchiveCache { fn default() -> Self { Self::new() } } ================================================ FILE: src/build_info.rs ================================================ /// Build information captured at compile time pub struct BuildInfo; impl BuildInfo { /// Get the package version from Cargo.toml pub fn version() -> &'static str { env!("CARGO_PKG_VERSION") } /// Get the build timestamp in YYYYMMDD.HHMMSS format pub fn build_timestamp() -> &'static str { env!("BUILD_TIMESTAMP") } /// Get the full git commit hash #[allow(dead_code)] pub fn git_hash() -> &'static str { env!("GIT_HASH") } /// Get the short git commit hash (first 7 characters) pub fn git_hash_short() -> &'static str { env!("GIT_HASH_SHORT") } /// Get the target platform (arch-os) pub fn target_platform() -> &'static str { env!("TARGET_PLATFORM") } /// Get the build profile (debug/release) pub fn build_profile() -> &'static str { env!("BUILD_PROFILE") } /// Get the combined build string (version.timestamp) pub fn build_string() -> &'static str { env!("BUILD_STRING") } /// Get the bundle version (macOS specific) #[cfg(target_os = "macos")] pub fn bundle_version() -> &'static str { env!("BUNDLE_VERSION") } /// Get a formatted version string for display pub fn display_version() -> String { format!("{} ({})", Self::version(), Self::build_timestamp()) } /// Get detailed build information for about dialogs #[allow(dead_code, unused_mut)] pub fn detailed_info() -> String { let mut info = format!( "Version: {}\nBuild: {}\nCommit: {}\nPlatform: {}\nProfile: {}", Self::version(), Self::build_timestamp(), Self::git_hash_short(), Self::target_platform(), Self::build_profile() ); #[cfg(target_os = "macos")] { info.push_str(&format!("\nBundle: {}", Self::bundle_version())); } info } /// Get bundle version information for display (returns empty string on non-macOS) pub fn bundle_version_display() -> &'static str { #[cfg(target_os = "macos")] { Self::bundle_version() } #[cfg(not(target_os = "macos"))] { "" } } /// Get enabled feature flags for display #[allow(unused_mut)] pub fn enabled_features() -> String { let mut features: Vec<&str> = Vec::new(); #[cfg(feature = "selection")] features.push("selection"); #[cfg(feature = "coco")] features.push("coco"); #[cfg(feature = "jp2")] features.push("jp2"); if features.is_empty() { "none".to_string() } else { features.join(", ") } } } ================================================ FILE: src/cache/cache_utils.rs ================================================ use std::io; #[allow(unused_imports)] use image::GenericImageView; use image::DynamicImage; use std::sync::Arc; use wgpu::{Device, Queue}; use iced_wgpu::wgpu; use iced_wgpu::engine::CompressionStrategy; use crate::cache::{compression::{compress_image_bc1, CompressionAlgorithm}}; use texpresso::{Format, Params, Algorithm, COLOUR_WEIGHTS_PERCEPTUAL}; #[allow(unused_imports)] use log::{debug, info, warn, error}; // Maximum texture size supported - matches the 8192x8192 limit mentioned in README const MAX_TEXTURE_SIZE: u32 = 8192; /// Checks if image exceeds MAX_TEXTURE_SIZE and resizes if needed while preserving aspect ratio pub fn check_and_resize_if_oversized(img: DynamicImage) -> DynamicImage { let (width, height) = img.dimensions(); if width > MAX_TEXTURE_SIZE || height > MAX_TEXTURE_SIZE { // Calculate scaling factor to fit within MAX_TEXTURE_SIZE while preserving aspect ratio let scale_factor = (MAX_TEXTURE_SIZE as f32 / width.max(height) as f32).min(1.0); let new_width = (width as f32 * scale_factor) as u32; let new_height = (height as f32 * scale_factor) as u32; warn!("Image {}x{} exceeds maximum texture size {}x{}. Resizing to {}x{} to prevent crashes.", width, height, MAX_TEXTURE_SIZE, MAX_TEXTURE_SIZE, new_width, new_height); img.resize(new_width, new_height, image::imageops::FilterType::Lanczos3) } else { debug!("Image {}x{} is within size limits, no resizing needed", width, height); img } } /// Loads an image with safety resizing for oversized images (>8192px) pub fn load_original_image(path_source: &crate::cache::img_cache::PathSource, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> Result { let img = { // Use PathSource-aware unified function let bytes = crate::file_io::read_image_bytes(path_source, archive_cache)?; crate::file_io::decode_image_from_bytes(&bytes) .map_err(|e| io::Error::new(e, format!("Failed to read image from PathSource: {}", path_source.file_name())))? }; Ok(check_and_resize_if_oversized(img)) } /// Loads and resizes an image to target dimensions, then applies safety size check pub fn load_and_resize_image(path_source: &crate::cache::img_cache::PathSource, target_width: u32, target_height: u32, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> Result { let img = { // Use PathSource-aware unified function let bytes = crate::file_io::read_image_bytes(path_source, archive_cache)?; crate::file_io::decode_image_from_bytes(&bytes) .map_err(|e| io::Error::new(e, format!("Failed to read image from PathSource: {}", path_source.file_name())))? }; let (original_width, original_height) = img.dimensions(); info!("Resizing image: {}x{} -> {}x{}", original_width, original_height, target_width, target_height); // First resize to target dimensions let resized_img = img.resize_exact(target_width, target_height, image::imageops::FilterType::Triangle); // Then apply safety check for oversized images Ok(check_and_resize_if_oversized(resized_img)) } fn convert_image_to_rgba(img: &DynamicImage) -> (Vec, u32, u32) { let rgba_image = img.to_rgba8(); let (width, height) = rgba_image.dimensions(); let rgba_bytes = rgba_image.into_raw(); (rgba_bytes, width, height) } /// Checks if BC1 compression should be used based on dimensions and strategy pub fn should_use_compression(width: u32, height: u32, strategy: CompressionStrategy) -> bool { match strategy { CompressionStrategy::Bc1 => { // BC1 compression requires dimensions to be multiples of 4 if width.is_multiple_of(4) && height.is_multiple_of(4) { debug!("Using BC1 compression for image ({} x {})", width, height); true } else { debug!("Image dimensions ({} x {}) not compatible with BC1. Using uncompressed format.", width, height); false } }, CompressionStrategy::None => false, } } /// Creates a texture with the appropriate format based on compression settings pub fn create_gpu_texture( device: &wgpu::Device, width: u32, height: u32, compression_strategy: CompressionStrategy, ) -> wgpu::Texture { let use_compression = should_use_compression(width, height, compression_strategy); device.create_texture(&wgpu::TextureDescriptor { label: Some(if use_compression { "CompressedTexture" } else { "LoadedTexture" }), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: if use_compression { wgpu::TextureFormat::Bc1RgbaUnormSrgb } else { wgpu::TextureFormat::Rgba8UnormSrgb }, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }) } /// Compresses image data using BC1 algorithm /// TODO: Remove this after confirming that the texpresso compression is stable #[allow(dead_code)] pub fn compress_image_data( rgba_data: &[u8], width: u32, height: u32, ) -> (Vec, u32) { // Compress the image data let compressed_blocks = compress_image_bc1( rgba_data, width as usize, height as usize, CompressionAlgorithm::RangeFit ); // Calculate compressed data layout let blocks_x = width.div_ceil(4); let bytes_per_block = 8; // BC1 uses 8 bytes per 4x4 block let row_bytes = blocks_x * bytes_per_block; // Flatten the blocks into a single buffer let compressed_data: Vec = compressed_blocks.iter() .flat_map(|block| block.iter().copied()) .collect(); (compressed_data, row_bytes) } /// Uploads uncompressed image data to a texture pub fn upload_uncompressed_texture( queue: &wgpu::Queue, texture: &wgpu::Texture, image_bytes: &[u8], width: u32, height: u32, ) { let bytes_per_row = width * 4; queue.write_texture( wgpu::ImageCopyTexture { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, image_bytes, wgpu::ImageDataLayout { offset: 0, bytes_per_row: Some(bytes_per_row), rows_per_image: None, }, wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, ); } /// Uploads compressed image data to a texture pub fn upload_compressed_texture( queue: &wgpu::Queue, texture: &wgpu::Texture, compressed_data: &[u8], width: u32, height: u32, row_bytes: u32, ) { queue.write_texture( wgpu::ImageCopyTexture { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, compressed_data, wgpu::ImageDataLayout { offset: 0, bytes_per_row: Some(row_bytes), rows_per_image: None, }, wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, ); } /// Compresses an image using the texpresso library (BC1/DXT1 format) pub fn compress_image_data_texpresso(image_data: &[u8], width: u32, height: u32) -> (Vec, u32) { // Create 4x4 blocks of RGBA data from the image let width_usize = width as usize; let height_usize = height as usize; // Calculate the output size let blocks_wide = width_usize.div_ceil(4); let blocks_tall = height_usize.div_ceil(4); let block_size = Format::Bc1.block_size(); let output_size = blocks_wide * blocks_tall * block_size; // Create output buffer let mut compressed_data = vec![0u8; output_size]; // Set up compression parameters let params = Params { //algorithm: Algorithm::ClusterFit, // Higher quality but still fast algorithm: Algorithm::RangeFit, weights: COLOUR_WEIGHTS_PERCEPTUAL, weigh_colour_by_alpha: true, // Better for images with transparency }; // Compress the image Format::Bc1.compress( image_data, width_usize, height_usize, params, &mut compressed_data ); // Calculate bytes per row let bytes_per_row = blocks_wide * block_size; (compressed_data, bytes_per_row as u32) } /// Creates and uploads a texture with the appropriate format and data pub fn create_and_upload_texture( device: &wgpu::Device, queue: &wgpu::Queue, image_data: &[u8], width: u32, height: u32, compression_strategy: CompressionStrategy, ) -> wgpu::Texture { let use_compression = should_use_compression(width, height, compression_strategy); let texture = create_gpu_texture(device, width, height, compression_strategy); if use_compression { // Use texpresso for compression when BC1 is selected match compression_strategy { CompressionStrategy::Bc1 => { let (compressed_data, bytes_per_row) = compress_image_data_texpresso(image_data, width, height); upload_compressed_texture(queue, &texture, &compressed_data, width, height, bytes_per_row); }, _ => { // Raise an error if an unsupported compression strategy is used panic!("Unsupported compression strategy: {:?}", compression_strategy); } } } else { upload_uncompressed_texture(queue, &texture, image_data, width, height); } texture } pub fn load_image_resized_sync( img_path: &crate::cache::img_cache::PathSource, is_slider_move: bool, device: &wgpu::Device, queue: &wgpu::Queue, existing_texture: &mut Arc, compression_strategy: CompressionStrategy, archive_cache: Option<&mut crate::archive_cache::ArchiveCache> ) -> Result<(), io::Error> { let img = if is_slider_move { load_and_resize_image(img_path, 1280, 720, archive_cache)? } else { load_original_image(img_path, archive_cache)? }; let (image_bytes, width, height) = convert_image_to_rgba(&img); // Use our new utility function to create and upload the texture let texture = Arc::new( create_and_upload_texture(device, queue, &image_bytes, width, height, compression_strategy) ); // Replace the old texture *existing_texture = texture; Ok(()) } /// Loads an image and resizes it to 720p if needed, then uploads it to GPU. pub async fn _load_image_resized( img_path: &crate::cache::img_cache::PathSource, is_slider_move: bool, device: &Device, queue: &Queue, existing_texture: &Arc, ) -> Result<(), io::Error> { // Use the appropriate loading function based on whether it's for slider or full-res let img = if is_slider_move { load_and_resize_image(img_path, 1280, 720, None) } else { load_original_image(img_path, None) }.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Failed to open image: {}", e)))?; let rgba_image = img.to_rgba8(); let (width, height) = rgba_image.dimensions(); let rgba_bytes = rgba_image.as_raw(); // 🔹 Align `bytes_per_row` to 256 bytes let unaligned_bytes_per_row = width * 4; let aligned_bytes_per_row = (unaligned_bytes_per_row + 255) & !255; // 🔹 Staging buffer let buffer_size = (aligned_bytes_per_row * height) as wgpu::BufferAddress; let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Staging Buffer"), size: buffer_size, usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::MAP_WRITE, mapped_at_creation: true, }); { let mut mapping = staging_buffer.slice(..).get_mapped_range_mut(); for row in 0..height { let src_start = (row * width * 4) as usize; let dst_start = (row * aligned_bytes_per_row) as usize; mapping[dst_start..dst_start + (width * 4) as usize] .copy_from_slice(&rgba_bytes[src_start..src_start + (width * 4) as usize]); } } staging_buffer.unmap(); let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Image Upload Encoder") }); encoder.copy_buffer_to_texture( wgpu::ImageCopyBuffer { buffer: &staging_buffer, layout: wgpu::ImageDataLayout { offset: 0, bytes_per_row: Some(aligned_bytes_per_row), rows_per_image: Some(height), }, }, wgpu::ImageCopyTexture { texture: existing_texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, ); queue.submit(Some(encoder.finish())); Ok(()) } ================================================ FILE: src/cache/compression.rs ================================================ //! A minimal BC1 compression library in pure Rust. // Remove `#![no_std]` since we need standard library features like Vec. /// Represents the compressed BC1 block (8 bytes). pub type Bc1Block = [u8; 8]; /// Represents an uncompressed 4x4 block of RGBA pixels. pub type RgbaBlock = [[u8; 4]; 16]; /// Defines available compression algorithms #[allow(dead_code)] #[derive(Clone, Copy, Debug)] #[derive(Default)] pub enum CompressionAlgorithm { /// RangeFit algorithm - faster with good quality #[default] RangeFit, } /// Converts RGB values to the RGB565 format. fn rgb_to_rgb565(r: u8, g: u8, b: u8) -> u16 { ((r as u16 >> 3) << 11) | ((g as u16 >> 2) << 5) | (b as u16 >> 3) } /// Computes the Euclidean distance between two colors. fn color_distance(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f32 { let dr = c1.0 as f32 - c2.0 as f32; let dg = c1.1 as f32 - c2.1 as f32; let db = c1.2 as f32 - c2.2 as f32; (dr * dr + dg * dg + db * db).sqrt() } /// Computes the principal axis using covariance matrix fn compute_principal_axis(colors: &[(u8, u8, u8)]) -> (f32, f32, f32) { let mut sum_r = 0.0f32; let mut sum_g = 0.0f32; let mut sum_b = 0.0f32; let mut count = 0.0f32; // Calculate mean color for &(r, g, b) in colors { sum_r += r as f32; sum_g += g as f32; sum_b += b as f32; count += 1.0; } let mean_r = sum_r / count; let mean_g = sum_g / count; let mean_b = sum_b / count; // Calculate covariance matrix let mut cov = [[0.0f32; 3]; 3]; for &(r, g, b) in colors { let dr = (r as f32) - mean_r; let dg = (g as f32) - mean_g; let db = (b as f32) - mean_b; cov[0][0] += dr * dr; cov[0][1] += dr * dg; cov[0][2] += dr * db; cov[1][1] += dg * dg; cov[1][2] += dg * db; cov[2][2] += db * db; } cov[1][0] = cov[0][1]; cov[2][0] = cov[0][2]; cov[2][1] = cov[1][2]; // Simple power iteration to find principal eigenvector let mut axis = (1.0f32, 1.0f32, 1.0f32); for _ in 0..4 { let x = cov[0][0] * axis.0 + cov[0][1] * axis.1 + cov[0][2] * axis.2; let y = cov[1][0] * axis.0 + cov[1][1] * axis.1 + cov[1][2] * axis.2; let z = cov[2][0] * axis.0 + cov[2][1] * axis.1 + cov[2][2] * axis.2; let length = (x * x + y * y + z * z).sqrt(); if length > 0.0 { axis = (x / length, y / length, z / length); } } axis } /// Compresses a 4x4 block using the RangeFit algorithm fn compress_bc1_block_rangefit(block: &RgbaBlock) -> Bc1Block { let mut colors: Vec<(u8, u8, u8)> = Vec::new(); // Collect non-transparent pixels for &pixel in block.iter() { if pixel[3] > 128 { colors.push((pixel[0], pixel[1], pixel[2])); } } if colors.is_empty() { return [0u8; 8]; } // Get principal axis let axis = compute_principal_axis(&colors); // Project colors onto principal axis let mut min_proj = f32::MAX; let mut max_proj = f32::MIN; let mut min_color = (0, 0, 0); let mut max_color = (0, 0, 0); for &(r, g, b) in &colors { let proj = (r as f32) * axis.0 + (g as f32) * axis.1 + (b as f32) * axis.2; if proj < min_proj { min_proj = proj; min_color = (r, g, b); } if proj > max_proj { max_proj = proj; max_color = (r, g, b); } } // Convert endpoints to RGB565 let color0 = rgb_to_rgb565(min_color.0, min_color.1, min_color.2); let color1 = rgb_to_rgb565(max_color.0, max_color.1, max_color.2); // Create color palette let mut palette = [min_color, max_color, (0, 0, 0), (0, 0, 0)]; if color0 > color1 { palette[2] = ( ((2 * (min_color.0 as u16) + max_color.0 as u16) / 3) as u8, ((2 * (min_color.1 as u16) + max_color.1 as u16) / 3) as u8, ((2 * (min_color.2 as u16) + max_color.2 as u16) / 3) as u8, ); palette[3] = ( ((min_color.0 as u16 + 2 * (max_color.0 as u16)) / 3) as u8, ((min_color.1 as u16 + 2 * (max_color.1 as u16)) / 3) as u8, ((min_color.2 as u16 + 2 * (max_color.2 as u16)) / 3) as u8, ); } else { palette[2] = ( ((min_color.0 as u16 + max_color.0 as u16) / 2) as u8, ((min_color.1 as u16 + max_color.1 as u16) / 2) as u8, ((min_color.2 as u16 + max_color.2 as u16) / 2) as u8, ); palette[3] = (0, 0, 0); } // Build indices let mut indices = 0u32; for (i, &pixel) in block.iter().enumerate() { let mut best_index = if pixel[3] <= 128 { 3 } else { 0 }; let mut best_distance = f32::MAX; if pixel[3] > 128 { for (j, &palette_color) in palette.iter().enumerate() { let distance = color_distance((pixel[0], pixel[1], pixel[2]), palette_color); if distance < best_distance { best_distance = distance; best_index = j; } } } indices |= (best_index as u32) << (2 * i); } let mut block_data = [0u8; 8]; block_data[0..2].copy_from_slice(&color0.to_le_bytes()); block_data[2..4].copy_from_slice(&color1.to_le_bytes()); block_data[4..8].copy_from_slice(&indices.to_le_bytes()); block_data } /// Compresses a 4x4 block of RGBA pixels using the specified algorithm pub fn compress_bc1_block(block: &RgbaBlock, algorithm: CompressionAlgorithm) -> Bc1Block { match algorithm { CompressionAlgorithm::RangeFit => compress_bc1_block_rangefit(block), } } /// Compresses an entire image of RGBA pixels pub fn compress_image_bc1( image: &[u8], width: usize, height: usize, algorithm: CompressionAlgorithm ) -> Vec { use rayon::prelude::*; // Calculate the positions of all blocks to process let blocks_x = width.div_ceil(4); let blocks_y = height.div_ceil(4); let total_blocks = blocks_x * blocks_y; // Create a parallel iterator for all block positions (0..total_blocks) .into_par_iter() .map(|block_idx| { let block_x = (block_idx % blocks_x) * 4; let block_y = (block_idx / blocks_x) * 4; // Build the 4x4 block let mut block = [[0u8; 4]; 16]; for by in 0..4 { for bx in 0..4 { let px = block_x + bx; let py = block_y + by; let idx = 4 * (py * width + px); if px < width && py < height { block[by * 4 + bx] = [ image[idx], image[idx + 1], image[idx + 2], image[idx + 3], ]; } } } // Compress the block and return it compress_bc1_block(&block, algorithm) }) .collect() } ================================================ FILE: src/cache/cpu_img_cache.rs ================================================ #[allow(unused_imports)] use log::{debug, info, warn, error}; use std::io; use crate::cache::img_cache::{CachedData, ImageCacheBackend, ImageMetadata}; use iced_wgpu::engine::CompressionStrategy; pub struct CpuImageCache; impl CpuImageCache { pub fn new() -> Self { CpuImageCache } } impl ImageCacheBackend for CpuImageCache { fn load_image( &self, index: usize, image_paths: &[crate::cache::img_cache::PathSource], #[allow(unused_variables)] compression_strategy: CompressionStrategy, archive_cache: Option<&mut crate::archive_cache::ArchiveCache> ) -> Result { if let Some(path_source) = image_paths.get(index) { debug!("CpuCache: Loading image from {:?}", path_source.file_name()); Ok(CachedData::Cpu(crate::file_io::read_image_bytes(path_source, archive_cache)?)) } else { Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid image index")) } } #[allow(clippy::needless_option_as_deref)] fn load_single_image( &mut self, image_paths: &[crate::cache::img_cache::PathSource], cache_count: usize, current_index: usize, cached_data: &mut Vec>, cached_metadata: &mut Vec>, cached_image_indices: &mut Vec, current_offset: &mut isize, #[allow(unused_variables)] compression_strategy: CompressionStrategy, mut archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result<(), io::Error> { // Calculate which cache slot to use for the current_index let cache_slot: usize; if current_index <= cache_count { cache_slot = current_index; *current_offset = -(cache_count as isize - current_index as isize); } else if current_index > (image_paths.len() - 1) - cache_count { cache_slot = cache_count + (cache_count as isize - ((image_paths.len()-1) as isize - current_index as isize)) as usize; *current_offset = cache_count as isize - ((image_paths.len()-1) as isize - current_index as isize); } else { cache_slot = cache_count; *current_offset = 0; } // Load only the single image at current_index if let Some(path_source) = image_paths.get(current_index) { match crate::file_io::read_image_bytes_with_size(path_source, archive_cache.as_deref_mut()) { Ok((bytes, file_size)) => { // Get dimensions efficiently using header-only read use std::io::Cursor; use image::ImageReader; let (width, height) = ImageReader::new(Cursor::new(&bytes)) .with_guessed_format() .ok() .and_then(|r| r.into_dimensions().ok()) .unwrap_or((0, 0)); cached_data[cache_slot] = Some(CachedData::Cpu(bytes)); cached_metadata[cache_slot] = Some(ImageMetadata::new(width, height, file_size)); cached_image_indices[cache_slot] = current_index as isize; debug!("CpuCache: Loaded single image at index {} into cache slot {}", current_index, cache_slot); }, Err(e) => { warn!("Failed to load image at index {}: {}. Skipping...", current_index, e); cached_data[cache_slot] = None; cached_metadata[cache_slot] = None; cached_image_indices[cache_slot] = -1; return Err(e); } } } else { return Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid image index")); } Ok(()) } #[allow(dead_code)] fn load_initial_images( &mut self, image_paths: &[crate::cache::img_cache::PathSource], cache_count: usize, current_index: usize, cached_data: &mut Vec>, cached_metadata: &mut Vec>, cached_image_indices: &mut Vec, current_offset: &mut isize, #[allow(unused_variables)] compression_strategy: CompressionStrategy, mut archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result<(), io::Error> { let start_index: isize; let end_index: isize; if current_index <= cache_count { start_index = 0; end_index = (cache_count * 2 + 1) as isize; *current_offset = -(cache_count as isize - current_index as isize); } else if current_index > (image_paths.len() - 1) - cache_count { start_index = image_paths.len() as isize - cache_count as isize * 2 - 1; end_index = image_paths.len() as isize; *current_offset = cache_count as isize - ((image_paths.len() - 1) as isize - current_index as isize); } else { start_index = current_index as isize - cache_count as isize; end_index = current_index as isize + cache_count as isize + 1; } for (i, cache_index) in (start_index..end_index).enumerate() { if cache_index < 0 { continue; } if cache_index > image_paths.len() as isize - 1 { break; } // Load image bytes with metadata if let Some(path_source) = image_paths.get(cache_index as usize) { match crate::file_io::read_image_bytes_with_size(path_source, archive_cache.as_deref_mut()) { Ok((bytes, file_size)) => { // Get dimensions efficiently using header-only read use std::io::Cursor; use image::ImageReader; let (width, height) = ImageReader::new(Cursor::new(&bytes)) .with_guessed_format() .ok() .and_then(|r| r.into_dimensions().ok()) .unwrap_or((0, 0)); cached_data[i] = Some(CachedData::Cpu(bytes)); cached_metadata[i] = Some(ImageMetadata::new(width, height, file_size)); cached_image_indices[i] = cache_index; }, Err(e) => { warn!("Failed to load image at index {}: {}. Skipping...", cache_index, e); cached_data[i] = None; cached_metadata[i] = None; cached_image_indices[i] = -1; // Mark as invalid } } } } Ok(()) } fn load_pos( &mut self, new_image: Option, pos: usize, image_index: isize, _cached_data: &mut Vec>, _cached_image_indices: &mut Vec, _cache_count: usize, #[allow(unused_variables)] _compression_strategy: CompressionStrategy, _archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result { match new_image { Some(CachedData::Cpu(_)) => { debug!("CpuCache: Setting image at position {}", pos); Ok(pos == image_index as usize) } _ => Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid data for CPU cache")), } } } ================================================ FILE: src/cache/gpu_img_cache.rs ================================================ #[allow(unused_imports)] use log::{debug, info, warn, error}; use std::io; use std::sync::Arc; use image::GenericImageView; use iced_wgpu::wgpu; use crate::cache::img_cache::{CachedData, ImageCacheBackend, ImageMetadata}; use iced_wgpu::engine::CompressionStrategy; pub struct GpuImageCache { device: Arc, queue: Arc, } impl GpuImageCache { pub fn new(device: Arc, queue: Arc) -> Self { Self { device, queue } } } impl ImageCacheBackend for GpuImageCache { fn load_image( &self, index: usize, image_paths: &[crate::cache::img_cache::PathSource], compression_strategy: CompressionStrategy, archive_cache: Option<&mut crate::archive_cache::ArchiveCache> ) -> Result { if let Some(path_source) = image_paths.get(index) { // Use the safe load_original_image function to prevent crashes with oversized images let img = crate::cache::cache_utils::load_original_image(path_source, archive_cache).map_err(|e| { io::Error::new(io::ErrorKind::InvalidData, format!("Failed to open image: {}", e)) })?; let rgba_image = img.to_rgba8(); let (width, height) = img.dimensions(); let rgba_data = rgba_image.into_raw(); // Use our utility function to determine if compression should be used let use_compression = crate::cache::cache_utils::should_use_compression( width, height, compression_strategy ); // Create the texture with the appropriate format let texture = crate::cache::cache_utils::create_gpu_texture( &self.device, width, height, compression_strategy ); if use_compression { // Use the utility to compress and upload let (compressed_data, row_bytes) = crate::cache::cache_utils::compress_image_data( &rgba_data, width, height ); // Upload using the utility function crate::cache::cache_utils::upload_compressed_texture( &self.queue, &texture, &compressed_data, width, height, row_bytes ); Ok(CachedData::BC1(texture.into())) } else { // Upload uncompressed using the utility function crate::cache::cache_utils::upload_uncompressed_texture( &self.queue, &texture, &rgba_data, width, height ); Ok(CachedData::Gpu(texture.into())) } } else { Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid image index")) } } #[allow(clippy::needless_option_as_deref)] fn load_single_image( &mut self, image_paths: &[crate::cache::img_cache::PathSource], cache_count: usize, current_index: usize, cached_data: &mut Vec>, cached_metadata: &mut Vec>, cached_image_indices: &mut Vec, current_offset: &mut isize, compression_strategy: CompressionStrategy, mut archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result<(), io::Error> { // Calculate which cache slot to use for the current_index let cache_slot: usize; if current_index <= cache_count { cache_slot = current_index; *current_offset = -(cache_count as isize - current_index as isize); } else if current_index > (image_paths.len() - 1) - cache_count { cache_slot = cache_count + (cache_count as isize - ((image_paths.len()-1) as isize - current_index as isize)) as usize; *current_offset = cache_count as isize - ((image_paths.len()-1) as isize - current_index as isize); } else { cache_slot = cache_count; *current_offset = 0; } // Load only the single image at current_index if let Some(path_source) = image_paths.get(current_index) { // Get file size efficiently without reading file content let file_size = crate::file_io::get_file_size(path_source, archive_cache.as_deref_mut()); // Load the image (this will read the file for actual decoding) match self.load_image(current_index, image_paths, compression_strategy, archive_cache.as_deref_mut()) { Ok(image) => { // Get dimensions from the loaded texture let (width, height) = match &image { CachedData::Gpu(texture) | CachedData::BC1(texture) => { let size = texture.size(); (size.width, size.height) }, CachedData::Cpu(_) => (0, 0), // Shouldn't happen in GPU cache }; cached_data[cache_slot] = Some(image); cached_metadata[cache_slot] = Some(ImageMetadata::new(width, height, file_size)); cached_image_indices[cache_slot] = current_index as isize; debug!("GpuCache: Loaded single image at index {} into cache slot {}", current_index, cache_slot); }, Err(e) => { warn!("Failed to load image at index {}: {}. Skipping...", current_index, e); cached_data[cache_slot] = None; cached_metadata[cache_slot] = None; cached_image_indices[cache_slot] = -1; return Err(e); } } } else { return Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid image index")); } Ok(()) } #[allow(dead_code)] fn load_initial_images( &mut self, image_paths: &[crate::cache::img_cache::PathSource], cache_count: usize, current_index: usize, cached_data: &mut Vec>, cached_metadata: &mut Vec>, cached_image_indices: &mut Vec, current_offset: &mut isize, compression_strategy: CompressionStrategy, mut archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result<(), io::Error> { let start_index: isize; let end_index: isize; if current_index <= cache_count { start_index = 0; end_index = (cache_count * 2 + 1) as isize; *current_offset = -(cache_count as isize - current_index as isize); } else if current_index > (image_paths.len() - 1) - cache_count { start_index = image_paths.len() as isize - cache_count as isize * 2 - 1; end_index = image_paths.len() as isize; *current_offset = cache_count as isize - ((image_paths.len() - 1) as isize - current_index as isize); } else { start_index = current_index as isize - cache_count as isize; end_index = current_index as isize + cache_count as isize + 1; } for (i, cache_index) in (start_index..end_index).enumerate() { if cache_index < 0 { continue; } if cache_index > image_paths.len() as isize - 1 { break; } // Load image and capture metadata if let Some(path_source) = image_paths.get(cache_index as usize) { // Get file size efficiently without reading file content let file_size = crate::file_io::get_file_size(path_source, archive_cache.as_deref_mut()); // Load the image (this will read the file for actual decoding) match self.load_image(cache_index as usize, image_paths, compression_strategy, archive_cache.as_deref_mut()) { Ok(image) => { // Get dimensions from the loaded texture let (width, height) = match &image { CachedData::Gpu(texture) | CachedData::BC1(texture) => { let size = texture.size(); (size.width, size.height) }, CachedData::Cpu(_) => (0, 0), // Shouldn't happen in GPU cache }; cached_data[i] = Some(image); cached_metadata[i] = Some(ImageMetadata::new(width, height, file_size)); cached_image_indices[i] = cache_index; }, Err(e) => { warn!("Failed to load image at index {}: {}. Skipping...", cache_index, e); cached_data[i] = None; cached_metadata[i] = None; cached_image_indices[i] = -1; // Mark as invalid } } } } Ok(()) } fn load_pos( &mut self, new_image: Option, pos: usize, image_index: isize, cached_data: &mut Vec>, cached_image_indices: &mut Vec, cache_count: usize, _compression_strategy: CompressionStrategy, _archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result { println!("GpuCache: Setting image at position {}", pos); if pos >= cached_data.len() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "Position out of bounds")); } // Store the new GPU texture in the cache cached_data[pos] = new_image; cached_image_indices[pos] = image_index; // Debugging output println!("Updated GPU cache at position {} with image index {}", pos, image_index); // If the position corresponds to the center of the cache, return true to trigger a reload let should_reload = pos == cache_count; Ok(should_reload) } } ================================================ FILE: src/cache/img_cache.rs ================================================ #[allow(unused_imports)] use std::time::Instant; #[allow(unused_imports)] use log::{debug, info, warn, error}; use std::path::PathBuf; use std::io; use std::collections::VecDeque; use std::sync::Arc; use iced_winit::runtime::Task; use iced_wgpu::wgpu; use crate::file_io::{empty_async_block_vec}; use crate::loading_status::LoadingStatus; use crate::app::Message; use crate::pane::Pane; use crate::pane; use crate::cache::cpu_img_cache::CpuImageCache; use crate::cache::gpu_img_cache::GpuImageCache; use crate::file_io; use iced_wgpu::engine::CompressionStrategy; #[derive(Debug, Clone, PartialEq)] pub enum LoadOperation { LoadNext((Vec, Vec>)), // Includes the target index ShiftNext((Vec, Vec>)), LoadPrevious((Vec, Vec>)), // Includes the target index ShiftPrevious((Vec, Vec>)), LoadPos((usize, Vec>)) // // Load an images into specific cache positions } #[derive(PartialEq, Debug, Clone, Copy)] pub enum LoadOperationType { LoadNext, ShiftNext, LoadPrevious, ShiftPrevious, LoadPos, } impl LoadOperation { pub fn operation_type(&self) -> LoadOperationType { match self { LoadOperation::LoadNext(..) => LoadOperationType::LoadNext, LoadOperation::ShiftNext(..) => LoadOperationType::ShiftNext, LoadOperation::LoadPrevious(..) => LoadOperationType::LoadPrevious, LoadOperation::ShiftPrevious(..) => LoadOperationType::ShiftPrevious, LoadOperation::LoadPos(..) => LoadOperationType::LoadPos, } } } #[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq)] pub enum CacheStrategy { Cpu, // Use CPU memory for image caching Gpu, // Use individual GPU textures } impl CacheStrategy { pub fn is_gpu_based(&self) -> bool { match self { CacheStrategy::Cpu => false, CacheStrategy::Gpu => true, } } } /// Metadata for cached images to avoid repeated decoding #[derive(Debug, Clone, Default)] pub struct ImageMetadata { pub width: u32, pub height: u32, pub file_size: u64, } impl ImageMetadata { pub fn new(width: u32, height: u32, file_size: u64) -> Self { Self { width, height, file_size } } /// Format resolution as "WIDTHxHEIGHT" string pub fn resolution_string(&self) -> String { format!("{}x{}", self.width, self.height) } /// Format file size as human-readable string (e.g., "2.5 MB") /// - use_binary: true = binary units (KiB/MiB, 1024 divisor) like `ls -lh` /// - use_binary: false = decimal units (KB/MB, 1000 divisor) like GNOME/macOS/Windows pub fn file_size_string(&self, use_binary: bool) -> String { let (divisor, kb_suffix, mb_suffix) = if use_binary { (1024.0, "KiB", "MiB") } else { (1000.0, "KB", "MB") }; if self.file_size < divisor as u64 { format!("{} B", self.file_size) } else if self.file_size < (divisor * divisor) as u64 { format!("{:.1} {}", self.file_size as f64 / divisor, kb_suffix) } else { format!("{:.1} {}", self.file_size as f64 / (divisor * divisor), mb_suffix) } } } #[derive(Debug, Clone)] pub enum CachedData { Cpu(Vec), // CPU: Raw image bytes Gpu(Arc), // GPU: Uncompressed texture BC1(Arc), // GPU: BC1 compressed texture } impl CachedData { pub fn take(self) -> Self { self } pub fn width(&self) -> u32 { self.dimensions().0 } pub fn height(&self) -> u32 { self.dimensions().1 } /// Get dimensions (width, height) efficiently - uses header-only read for CPU images. /// Accounts for EXIF orientation (90/270 rotations swap dimensions). pub fn dimensions(&self) -> (u32, u32) { match self { CachedData::Cpu(data) => { // Use EXIF-aware dimensions to account for orientation crate::exif_utils::get_orientation_aware_dimensions(data) }, CachedData::Gpu(texture) => (texture.width(), texture.height()), CachedData::BC1(texture) => (texture.width(), texture.height()), } } pub fn handle(&self) -> Option { match self { CachedData::Cpu(data) => { Some(iced_core::image::Handle::from_bytes(data.clone())) }, CachedData::Gpu(_) => None, // No CPU handle for GPU-based textures CachedData::BC1(_) => None, // No CPU handle for BC1 compressed textures } } } impl CachedData { pub fn len(&self) -> usize { match self { CachedData::Cpu(data) => data.len(), CachedData::Gpu(texture) => { let width = texture.width(); let height = texture.height(); 4 * (width as usize) * (height as usize) // 4 bytes per pixel (RGBA8) } CachedData::BC1(texture) => { // BC1 uses 8 bytes per 4x4 block, which is 0.5 bytes per pixel let width = texture.width(); let height = texture.height(); // Round up to nearest multiple of 4 if needed let block_width = width.div_ceil(4); let block_height = height.div_ceil(4); // Each 4x4 block is 8 bytes in BC1 (block_width * block_height * 8) as usize } } } pub fn as_vec(&self) -> Result, io::Error> { match self { CachedData::Cpu(data) => Ok(data.clone()), CachedData::Gpu(_) => Err(io::Error::new( io::ErrorKind::Unsupported, "GPU data cannot be converted to a Vec", )), CachedData::BC1(_) => todo!() } } pub fn is_compressed(&self) -> bool { matches!(self, CachedData::BC1(_)) } pub fn compression_format(&self) -> Option<&'static str> { match self { CachedData::BC1(_) => Some("BC1"), _ => None, } } } /// PathSource enum for type-safe image loading with performance optimization #[derive(Clone, Debug)] pub enum PathSource { /// Regular filesystem file - direct filesystem I/O Filesystem(PathBuf), /// Archive internal path - requires archive reading Archive(PathBuf), /// Preloaded archive content - available in ArchiveCache HashMap Preloaded(PathBuf), } impl PathSource { /// Get the underlying PathBuf for any variant pub fn path(&self) -> &PathBuf { match self { PathSource::Filesystem(path) => path, PathSource::Archive(path) => path, PathSource::Preloaded(path) => path, } } /// Get filename for display/sorting purposes pub fn file_name(&self) -> std::borrow::Cow<'_, str> { match self { PathSource::Filesystem(_) => { self.path().file_name() .unwrap_or_default() .to_string_lossy() }, _ => { std::borrow::Cow::from(self.path().display().to_string()) } } } } #[allow(dead_code)] pub trait ImageCacheBackend { fn load_image( &self, index: usize, image_paths: &[PathSource], compression_strategy: CompressionStrategy, archive_cache: Option<&mut crate::archive_cache::ArchiveCache> ) -> Result; #[allow(clippy::too_many_arguments)] fn load_single_image( &mut self, image_paths: &[PathSource], cache_count: usize, current_index: usize, cached_data: &mut Vec>, cached_metadata: &mut Vec>, cached_image_indices: &mut Vec, current_offset: &mut isize, compression_strategy: CompressionStrategy, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result<(), io::Error>; #[allow(dead_code)] #[allow(clippy::too_many_arguments)] fn load_initial_images( &mut self, image_paths: &[PathSource], cache_count: usize, current_index: usize, cached_data: &mut Vec>, cached_metadata: &mut Vec>, cached_image_indices: &mut Vec, current_offset: &mut isize, compression_strategy: CompressionStrategy, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result<(), io::Error>; #[allow(dead_code)] #[allow(clippy::too_many_arguments)] fn load_pos( &mut self, new_image: Option, pos: usize, image_index: isize, cached_data: &mut Vec>, cached_image_indices: &mut Vec, cache_count: usize, compression_strategy: CompressionStrategy, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result; } pub struct ImageCache { pub image_paths: Vec, pub num_files: usize, pub current_index: usize, pub current_offset: isize, pub cache_count: usize, pub cached_image_indices: Vec, // Indices of cached images pub cache_states: Vec, // States of cache validity pub loading_queue: VecDeque, pub being_loaded_queue: VecDeque, // Queue of image indices being loaded pub cached_data: Vec>, // Caching mechanism pub cached_metadata: Vec>, // Metadata parallel to cached_data pub backend: Box, // Backend determines caching type pub slider_texture: Option>, pub compression_strategy: CompressionStrategy, } impl Default for ImageCache { fn default() -> Self { ImageCache { image_paths: Vec::new(), num_files: 0, current_index: 0, current_offset: 0, cache_count: 0, cached_image_indices: Vec::new(), cache_states: Vec::new(), loading_queue: VecDeque::new(), being_loaded_queue: VecDeque::new(), cached_data: Vec::new(), cached_metadata: Vec::new(), backend: Box::new(CpuImageCache {}), slider_texture: None, compression_strategy: CompressionStrategy::None, } } } // Constructor, cached_data getter / setter, and type specific methods #[allow(dead_code)] impl ImageCache { pub fn new( image_paths: &[PathSource], cache_count: usize, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, initial_index: usize, device: Option>, queue: Option>, ) -> Self { let cache_size = cache_count * 2 + 1; let mut cached_data = Vec::new(); let mut cached_metadata = Vec::new(); for _ in 0..cache_size { cached_data.push(None); cached_metadata.push(None); } // Initialize the image cache with the basic structure let mut image_cache = ImageCache { image_paths: image_paths.to_owned(), num_files: image_paths.len(), current_index: initial_index, current_offset: 0, cache_count, cached_data, cached_metadata, cached_image_indices: vec![-1; cache_size], cache_states: vec![false; cache_size], loading_queue: VecDeque::new(), being_loaded_queue: VecDeque::new(), slider_texture: None, backend: Box::new(CpuImageCache {}), // Temporary CPU backend, will be replaced compression_strategy, }; // Initialize the slider texture if using GPU if cache_strategy.is_gpu_based() { if let Some(device) = device.clone() { image_cache.slider_texture = Some(Arc::new(device.create_texture(&wgpu::TextureDescriptor { label: Some("SliderTexture"), size: wgpu::Extent3d { width: 1280, // Fixed 720p resolution height: 720, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8UnormSrgb, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }))); } } // Initialize the appropriate backend image_cache.init_cache(device, queue, cache_strategy, compression_strategy); image_cache } pub fn _get_cached_data(&self, index: usize) -> Option<&CachedData> { self.cached_data.get(index).and_then(|opt| opt.as_ref()) } pub fn set_cached_data(&mut self, index: usize, data: CachedData) { if index < self.cached_data.len() { self.cached_data[index] = Some(data); } } pub fn set_cached_metadata(&mut self, index: usize, metadata: ImageMetadata) { if index < self.cached_metadata.len() { self.cached_metadata[index] = Some(metadata); } } pub fn load_image(&self, index: usize, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> Result { self.backend.load_image(index, &self.image_paths, self.compression_strategy, archive_cache) } pub fn _load_pos( &mut self, new_data: Option, pos: usize, data_index: isize, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>, ) -> Result { //self.backend.load_pos(new_data, pos, data_index) self.backend.load_pos( new_data, pos, data_index, &mut self.cached_data, &mut self.cached_image_indices, self.cache_count, self.compression_strategy, archive_cache, ) } pub fn load_single_image(&mut self, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> Result<(), io::Error> { self.backend.load_single_image( &self.image_paths, self.cache_count, self.current_index, &mut self.cached_data, &mut self.cached_metadata, &mut self.cached_image_indices, &mut self.current_offset, self.compression_strategy, archive_cache, ) } #[allow(dead_code)] pub fn load_initial_images(&mut self, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> Result<(), io::Error> { self.backend.load_initial_images( &self.image_paths, self.cache_count, self.current_index, &mut self.cached_data, &mut self.cached_metadata, &mut self.cached_image_indices, &mut self.current_offset, self.compression_strategy, archive_cache, ) } pub fn shift_cache_left(&mut self, new_item: Option, new_metadata: Option) { self.cached_data.remove(0); self.cached_data.push(new_item); // Shift metadata in parallel self.cached_metadata.remove(0); self.cached_metadata.push(new_metadata); // Update indices self.cached_image_indices.remove(0); if !self.cached_image_indices.is_empty() { let next_index = self.cached_image_indices[self.cached_image_indices.len()-1] + 1; self.cached_image_indices.push(next_index); } else { self.cached_image_indices.push(0); } self.current_offset -= 1; debug!("shift_cache_left - current_offset: {}", self.current_offset); } pub fn init_cache( &mut self, device: Option>, queue: Option>, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, ) { let backend: Box = match cache_strategy { CacheStrategy::Cpu => Box::new(CpuImageCache::new()), CacheStrategy::Gpu => { if let (Some(device), Some(queue)) = (device, queue) { Box::new(GpuImageCache::new(device, queue)) } else { Box::new(CpuImageCache::new()) } }, }; //self.backend = Some(backend); self.backend = backend; self.compression_strategy = compression_strategy; } pub fn _set_compression_strategy(&mut self, strategy: CompressionStrategy) { self.compression_strategy = strategy; } } // Methods independent of cache type impl ImageCache { #[allow(dead_code)] pub fn print_cache(&self) { for (index, image_option) in self.cached_data.iter().enumerate() { match image_option { Some(image_bytes) => { let image_info = format!("Image {} - Index {} - Size: {} bytes", index, self.cached_image_indices[index], image_bytes.len()); debug!("{}", image_info); } None => { let no_image_info = format!("No image at index {}", index); debug!("{}", no_image_info); } } } } #[allow(dead_code)] pub fn print_cache_index(&self) { for (index, cache_index) in self.cached_image_indices.iter().enumerate() { let index_info = format!("Index {} - Cache Index: {}", index, cache_index); debug!("{}", index_info); } } #[allow(dead_code)] pub fn clear_cache(&mut self) { // Clear all collections self.cached_data.clear(); self.cached_metadata.clear(); self.cached_image_indices.clear(); self.cache_states.clear(); self.image_paths.clear(); self.num_files = 0; self.current_index = 0; self.current_offset = 0; self.cache_count = 0; self.slider_texture = None; // Clear the loading queues self.loading_queue.clear(); self.being_loaded_queue.clear(); // Reinitialize the cached_data and cached_metadata vectors let cache_size = self.cache_count * 2 + 1; let mut cached_data = Vec::new(); let mut cached_metadata = Vec::new(); for _ in 0..cache_size { cached_data.push(None); cached_metadata.push(None); } self.cached_data = cached_data; self.cached_metadata = cached_metadata; self.cache_states = vec![false; self.image_paths.len()]; } pub fn move_next(&mut self, new_image: Option, new_metadata: Option, _image_index: isize) -> Result { if self.current_index < self.image_paths.len() - 1 { //shift_cache_left(&mut self.cached_data, &mut self.cached_image_indices, new_image, &mut self.current_offset); self.shift_cache_left(new_image, new_metadata); Ok(false) } else { Err(io::Error::other("No more images to display")) } } pub fn move_prev(&mut self, new_image: Option, new_metadata: Option, _image_index: isize) -> Result { if self.current_index > 0 { //shift_cache_right(&mut self.cached_data, &mut self.cached_image_indices, new_image, &mut self.current_offset); self.shift_cache_right(new_image, new_metadata); Ok(false) } else { Err(io::Error::other("No previous images to display")) } } pub fn move_next_edge(&self, _new_image: Option, _image_index: isize) -> Result { if self.current_index < self.image_paths.len() - 1 { Ok(false) } else { Err(io::Error::other("No more images to display")) } } pub fn move_prev_edge(&self, _new_image: Option, _image_index: isize) -> Result { if self.current_index > 0 { Ok(false) } else { Err(io::Error::other("No previous images to display")) } } pub fn shift_cache_right( &mut self, new_item: Option, new_metadata: Option, ) { // Shift the elements in cached_images to the right self.cached_data.pop(); // Remove the last (rightmost) element self.cached_data.insert(0, new_item); // Shift metadata in parallel self.cached_metadata.pop(); self.cached_metadata.insert(0, new_metadata); // Update indices self.cached_image_indices.pop(); let prev_index = self.cached_image_indices[0] - 1; self.cached_image_indices.insert(0, prev_index); self.current_offset += 1; debug!("shift_cache_right - current_offset: {}", self.current_offset); } pub fn get_initial_image(&self) -> Result<&CachedData, io::Error> { let cache_index = (self.cache_count as isize + self.current_offset) as usize; if let Some(image_data_option) = self.cached_data.get(cache_index) { if let Some(image_data) = image_data_option { Ok(image_data) } else { Err(io::Error::other("Image data is not cached")) } } else { Err(io::Error::other("Invalid cache index")) } } /// Gets the initial image as CPU data, loading from file if necessary /// This is useful for slider images which need Vec data pub fn get_initial_image_as_cpu(&self, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> Result, io::Error> { // First try to get from cache match self.get_initial_image() { Ok(cached_data) => { // If it's already CPU data, return it match cached_data.as_vec() { Ok(bytes) => Ok(bytes), Err(_) => { // If it's GPU data, we need to load from file instead let cache_index = (self.cache_count as isize + self.current_offset) as usize; let image_index = self.cached_image_indices[cache_index]; if image_index >= 0 && (image_index as usize) < self.image_paths.len() { // Load directly from file let img_path = &self.image_paths[image_index as usize]; crate::file_io::read_image_bytes(img_path, archive_cache) } else { Err(io::Error::other("Invalid image index")) } } } }, Err(err) => Err(err) } } #[allow(dead_code)] pub fn get_current_image(&self) -> Result<&CachedData, io::Error> { let cache_index = self.cache_count; // center element of the cache debug!(" Current index: {}, Cache index: {}", self.current_index, cache_index); // Display information about each image /*for (index, image_option) in self.cached_data.iter().enumerate() { match image_option { Some(image_bytes) => { let image_info = format!(" Image {} - Size: {} bytes", index, image_bytes.len()); debug!("{}", image_info); } None => { let no_image_info = format!(" No image at index {}", index); debug!("{}", no_image_info); } } }*/ if let Some(image_data_option) = self.cached_data.get(cache_index) { if let Some(image_data) = image_data_option { Ok(image_data) } else { Err(io::Error::other("Image data is not cached")) } } else { Err(io::Error::other("Invalid cache index")) } } pub fn get_image_by_index(&self, index: usize) -> Result<&CachedData, io::Error> { debug!("current index: {}, cached_images.len(): {}", self.current_index, self.cached_data.len()); if let Some(image_data_option) = self.cached_data.get(index) { if let Some(image_data) = image_data_option { Ok(image_data) } else { Err(io::Error::other("Image data is not cached")) } } else { Err(io::Error::other("Invalid cache index")) } } /// Get metadata for the initial (current) image pub fn get_initial_metadata(&self) -> Option<&ImageMetadata> { let cache_index = (self.cache_count as isize + self.current_offset) as usize; self.cached_metadata.get(cache_index).and_then(|opt| opt.as_ref()) } pub fn get_next_cache_index(&self) -> isize { self.cache_count as isize + self.current_offset + 1 } #[allow(clippy::let_and_return)] pub fn get_next_image_to_load(&self) -> usize { let next_image_index = (self.current_index as isize + (self.cache_count as isize - self.current_offset)) as usize + 1; next_image_index } pub fn get_prev_image_to_load(&self) -> usize { let prev_image_index_to_load = (self.current_index as isize + (-(self.cache_count as isize) - self.current_offset)) - 1; prev_image_index_to_load as usize } pub fn is_some_at_index(&self, index: usize) -> bool { // Using pattern matching to check if element is None if let Some(image_data_option) = self.cached_data.get(index) { matches!(image_data_option, Some(_image_data)) } else { false } } pub fn is_cache_index_within_bounds(&self, index: usize) -> bool { if !(0..self.cached_data.len()).contains(&index) { debug!("is_cache_index_within_bounds - index: {}, cached_images.len(): {}", index, self.cached_data.len()); return false; } self.is_some_at_index(index) } pub fn is_next_cache_index_within_bounds(&self) -> bool { let next_image_index_to_render: usize = self.get_next_cache_index() as usize; if next_image_index_to_render >= self.image_paths.len() { return false; } self.is_cache_index_within_bounds(next_image_index_to_render) } pub fn is_prev_cache_index_within_bounds(&self) -> bool { let prev_image_index_to_render: isize = self.cache_count as isize + self.current_offset - 1; if prev_image_index_to_render < 0 { return false; } debug!("is_prev_cache_index_within_bounds - prev_image_index_to_render: {}", prev_image_index_to_render); self.print_cache(); self.is_cache_index_within_bounds(prev_image_index_to_render as usize) } pub fn is_image_index_within_bounds(&self, index: isize) -> bool { index < 0 && index >= -(self.cache_count as isize) || index >= 0 && index < self.image_paths.len() as isize || index >= self.image_paths.len() as isize && index < self.image_paths.len() as isize + self.cache_count as isize } pub fn _is_operation_in_queues(&self, operation: LoadOperationType) -> bool { debug!("img_cache.loading_queue: {:?}", self.loading_queue); debug!("img_cache.being_loaded_queue: {:?}", self.being_loaded_queue); self.loading_queue.iter().any(|op| op.operation_type() == operation) || self.being_loaded_queue.iter().any(|op| op.operation_type() == operation) } pub fn is_operation_blocking(&self, operation: LoadOperationType) -> bool { match operation { LoadOperationType::LoadNext => { if self.current_offset == -(self.cache_count as isize) { return true; } } LoadOperationType::LoadPrevious => { if self.current_offset == self.cache_count as isize { return true; } } _ => {} } false } /// If there are certain loading operations in the queue and the new loading op would cause bugs, return true /// e.g. When current_offset==5 and LoadPrevious op is at the head of the queue(queue.front()), /// the new op is LoadNext: this would make current_offset==6 and cache would be out of bounds pub fn is_blocking_loading_ops_in_queue( &self, loading_operation: LoadOperation, loading_status: &LoadingStatus ) -> bool { match loading_operation { LoadOperation::LoadNext((_cache_index, _target_index)) => { if self.current_offset == -(self.cache_count as isize) { return true; } if self.current_offset == self.cache_count as isize { if loading_status.being_loaded_queue.is_empty() { return false; } if let Some(op) = loading_status.being_loaded_queue.front() { debug!("is_blocking_loading_ops_in_queue - op: {:?}", op); match op { LoadOperation::LoadPrevious((_c_index, _img_index)) => { return true; } LoadOperation::ShiftPrevious((_c_index, _img_index)) => { return true; } _ => {} } } } } LoadOperation::LoadPrevious((_cache_index, _target_index)) => { if self.current_offset == self.cache_count as isize { return true; } if self.current_offset == -(self.cache_count as isize) { if let Some(op) = self.being_loaded_queue.front() { match op { LoadOperation::LoadNext((_c_index, _img_index)) => { return true; } LoadOperation::ShiftNext((_c_index, _img_index)) => { return true; } _ => {} } } } } _ => {} } false } } #[allow(clippy::too_many_arguments)] pub fn load_images_by_operation_slider( device: &Arc, queue: &Arc, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut [pane::Pane], pane_index: usize, target_indices_and_cache: &[Option<(isize, usize)>], operation: LoadOperation ) -> Task { let mut paths = Vec::new(); let mut archive_caches = Vec::new(); // Ensure we access the correct pane by the pane_index if let Some(pane) = panes.get_mut(pane_index) { let img_cache = &mut pane.img_cache; // Loop over the target indices and cache positions for target in target_indices_and_cache.iter() { if let Some((target_index, cache_pos)) = target { if let Some(path) = img_cache.image_paths.get(*target_index as usize) { paths.push(Some(path.clone())); if pane.has_compressed_file { archive_caches.push(Some(Arc::clone(&pane.archive_cache))); } else { archive_caches.push(None); } // Store the target image at the specified cache position img_cache.cached_image_indices[*cache_pos] = *target_index; } else { paths.push(None); archive_caches.push(None); } } else { paths.push(None); archive_caches.push(None); } } // If we have valid paths, proceed to load the images asynchronously if !paths.is_empty() { let device_clone = Arc::clone(device); let queue_clone = Arc::clone(queue); // Check if the pane has compressed files and get the archive cache let _archive_cache = if pane.has_compressed_file { Some(Arc::clone(&pane.archive_cache)) } else { None }; debug!("Task::perform started for {:?}", operation); let images_loading_task = async move { file_io::load_images_async( paths, cache_strategy, &device_clone, &queue_clone, compression_strategy, operation, archive_caches ).await }; Task::perform(images_loading_task, Message::ImagesLoaded) } else { Task::none() } } else { debug!("Pane not found for pane_index: {}", pane_index); Task::none() } } pub fn load_images_by_indices( device: &Arc, queue: &Arc, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut Vec<&mut Pane>, target_indices: &[Option], operation: LoadOperation ) -> Task { let mut paths = Vec::new(); let mut archive_caches = Vec::new(); for (pane_index, pane) in panes.iter_mut().enumerate() { let img_cache = &mut pane.img_cache; if let Some(target_index) = target_indices[pane_index] { if let Some(path) = img_cache.image_paths.get(target_index as usize) { paths.push(Some(path.clone())); // Add archive cache if this pane has compressed files if pane.has_compressed_file { archive_caches.push(Some(Arc::clone(&pane.archive_cache))); } else { archive_caches.push(None); } } else { paths.push(None); archive_caches.push(None); } } else { paths.push(None); archive_caches.push(None); } } if !paths.is_empty() { let device_clone = Arc::clone(device); let queue_clone = Arc::clone(queue); debug!("Task::perform started for {:?}", operation); Task::perform( async move { let result = file_io::load_images_async( paths, cache_strategy, &device_clone, &queue_clone, compression_strategy, operation, archive_caches ).await; result }, Message::ImagesLoaded, ) } else { Task::none() } } pub fn load_images_by_operation( device: &Arc, queue: &Arc, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut Vec<&mut Pane>, loading_status: &mut LoadingStatus ) -> Task { if !loading_status.loading_queue.is_empty() { debug!("load_images_by_operation - loading_status.loading_queue: {:?}", loading_status.loading_queue); if let Some(operation) = loading_status.loading_queue.pop_front() { loading_status.enqueue_image_being_loaded(operation.clone()); debug!("load_images_by_operation - loading_status.being_loaded_queue: {:?}", loading_status.being_loaded_queue); match operation { LoadOperation::LoadNext((ref _pane_indices, ref target_indicies)) => { load_images_by_indices( device, queue, cache_strategy, compression_strategy, panes, target_indicies, operation.clone() ) } LoadOperation::LoadPrevious((ref _pane_indices, ref target_indicies)) => { load_images_by_indices( device, queue, cache_strategy, compression_strategy, panes, target_indicies, operation.clone() ) } LoadOperation::ShiftNext((ref _pane_indices, ref _target_indicies)) => { let empty_async_block = empty_async_block_vec(operation, panes.len()); Task::perform(empty_async_block, Message::ImagesLoaded) } LoadOperation::ShiftPrevious((ref _pane_indices, ref _target_indicies)) => { let empty_async_block = empty_async_block_vec(operation, panes.len()); Task::perform(empty_async_block, Message::ImagesLoaded) } LoadOperation::LoadPos((ref _pane_indices, _target_indices_and_cache)) => { Task::none() } } } else { Task::none() } } else { Task::none() } } pub fn load_all_images_in_queue( device: &Arc, queue: &Arc, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut [pane::Pane], loading_status: &mut LoadingStatus, ) -> Task { let mut tasks = Vec::new(); let mut pane_refs: Vec<&mut pane::Pane> = vec![]; // Collect references to panes for pane in panes.iter_mut() { pane_refs.push(pane); } debug!( "##load_all_images_in_queue - loading_status.loading_queue: {:?}", loading_status.loading_queue ); loading_status.print_queue(); // Process each operation in the loading queue while let Some(operation) = loading_status.loading_queue.pop_front() { loading_status.enqueue_image_being_loaded(operation.clone()); if let LoadOperation::LoadPos((ref pane_index, ref target_indices_and_cache)) = operation { let task = load_images_by_operation_slider( device, queue, cache_strategy, compression_strategy, panes, *pane_index, target_indices_and_cache, operation.clone(), ); tasks.push(task); } } // Return the batch of tasks if any, otherwise return none if tasks.is_empty() { Task::none() } else { Task::batch(tasks) } } ================================================ FILE: src/cache/mod.rs ================================================ pub mod img_cache; pub mod cpu_img_cache; pub mod gpu_img_cache; pub mod cache_utils; pub mod texture_cache; pub mod compression; ================================================ FILE: src/cache/texture_cache.rs ================================================ use iced_wgpu::wgpu; use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; use std::hash::{Hash, Hasher}; #[allow(unused_imports)] use log::{debug, info, warn}; use image::GenericImageView; /// A simple cache for GPU textures created from CPU images. /// This avoids recreating textures for the same image data. #[derive(Debug)] pub struct TextureCache { /// Map from image content hash to texture textures: HashMap>, /// Statistics hits: usize, misses: usize, last_cleared: Instant, } impl TextureCache { pub fn new() -> Self { Self { textures: HashMap::new(), hits: 0, misses: 0, last_cleared: Instant::now(), } } /// Get a cached texture or create a new one pub fn get_or_create_texture( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, image_bytes: &[u8], _dimensions: (u32, u32), ) -> Option> { // Safety check for empty data if image_bytes.is_empty() { warn!("TextureCache: Cannot create texture from empty image data"); return None; } // Calculate a simple hash of the image data let hash_start = Instant::now(); let hash = self.hash_image(image_bytes); let hash_time = hash_start.elapsed(); debug!("TextureCache: Computed hash in {:?}", hash_time); if let Some(texture) = self.textures.get(&hash) { self.hits += 1; if self.hits.is_multiple_of(100) { debug!("TextureCache: {} hits, {} misses", self.hits, self.misses); } debug!("TextureCache: Cache hit for hash {}", hash); return Some(Arc::clone(texture)); } // Cache miss - create new texture self.misses += 1; debug!("TextureCache: Creating new texture (hash: {})", hash); let load_start = Instant::now(); match crate::exif_utils::decode_with_exif_orientation(image_bytes) { Ok(img) => { let load_time = load_start.elapsed(); debug!("TextureCache: Loaded image in {:?}", load_time); let rgba_start = Instant::now(); let rgba = img.to_rgba8(); let rgba_time = rgba_start.elapsed(); debug!("TextureCache: Converted to RGBA in {:?}", rgba_time); let dimensions = img.dimensions(); if dimensions.0 == 0 || dimensions.1 == 0 { warn!("TextureCache: Invalid image dimensions: {}x{}", dimensions.0, dimensions.1); return None; } // Create the texture let texture_start = Instant::now(); let texture = device.create_texture( &wgpu::TextureDescriptor { label: Some("CPU Image Texture"), size: wgpu::Extent3d { width: dimensions.0, height: dimensions.1, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8UnormSrgb, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], } ); let texture_create_time = texture_start.elapsed(); debug!("TextureCache: Created texture in {:?}", texture_create_time); // Write the image data to the texture let upload_start = Instant::now(); queue.write_texture( wgpu::ImageCopyTexture { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, bytemuck::cast_slice(rgba.as_raw()), wgpu::ImageDataLayout { offset: 0, bytes_per_row: Some(4 * dimensions.0), rows_per_image: Some(dimensions.1), }, wgpu::Extent3d { width: dimensions.0, height: dimensions.1, depth_or_array_layers: 1, }, ); let upload_time = upload_start.elapsed(); debug!("TextureCache: Uploaded texture data in {:?}", upload_time); let texture_arc = Arc::new(texture); self.textures.insert(hash, Arc::clone(&texture_arc)); // Maybe clean up old textures self.maybe_cleanup(); Some(texture_arc) }, Err(e) => { warn!("TextureCache: Failed to load image: {:?}", e); None } } } /// Generate a simple hash for the image data fn hash_image(&self, bytes: &[u8]) -> u64 { use std::collections::hash_map::DefaultHasher; let mut hasher = DefaultHasher::new(); // For large images, hash just a sample of bytes to improve performance if bytes.len() > 1024 { // Hash image length bytes.len().hash(&mut hasher); // Hash the first 512 bytes if bytes.len() >= 512 { bytes[..512].hash(&mut hasher); } // Hash the last 512 bytes if bytes.len() >= 1024 { bytes[bytes.len() - 512..].hash(&mut hasher); } // Hash some bytes from the middle if bytes.len() >= 1536 { let mid = bytes.len() / 2; bytes[mid - 256..mid + 256].hash(&mut hasher); } } else { // For small images, hash everything bytes.hash(&mut hasher); } hasher.finish() } /// Clean up the cache periodically to avoid memory leaks fn maybe_cleanup(&mut self) { let now = Instant::now(); // Clear the cache every 5 minutes if now.duration_since(self.last_cleared).as_secs() > 300 && self.textures.len() > 100 { debug!("TextureCache: Clearing cache ({} entries)", self.textures.len()); self.textures.clear(); self.last_cleared = now; } } /// Get cache statistics pub fn _stats(&self) -> (usize, usize) { (self.hits, self.misses) } } impl Default for TextureCache { fn default() -> Self { Self::new() } } ================================================ FILE: src/coco/annotation_manager.rs ================================================ /// Annotation manager for COCO datasets /// /// Manages loading, caching, and accessing COCO annotations. /// Associates annotation files with image directories. use std::collections::HashMap; use std::path::PathBuf; use log::{info, warn}; use super::parser::{CocoDataset, ImageAnnotation}; /// Manages COCO annotations for the current session pub struct AnnotationManager { /// Currently loaded dataset (if any) current_dataset: Option, /// Path to the currently loaded COCO JSON file current_json_path: Option, } /// A loaded COCO dataset with its associated directory struct LoadedDataset { /// The parsed COCO dataset dataset: CocoDataset, /// Image directory associated with this dataset #[allow(dead_code)] image_directory: PathBuf, /// Cached lookup map: filename -> annotations annotation_map: HashMap>, /// Set of image IDs that had invalid annotations images_with_invalid_annos: std::collections::HashSet, } impl AnnotationManager { /// Create a new annotation manager pub fn new() -> Self { Self { current_dataset: None, current_json_path: None, } } /// Load a COCO dataset from a JSON file /// /// Returns Ok(true) if image directory was found automatically, /// Ok(false) if caller needs to prompt for image directory, /// Err if parsing failed #[allow(dead_code)] pub fn load_coco_file(&mut self, json_path: PathBuf) -> Result { info!("Loading COCO file: {}", json_path.display()); // Parse the COCO JSON let mut dataset = CocoDataset::from_file(&json_path)?; // Validate and clean the dataset let (skipped_count, warnings, images_with_invalid) = dataset.validate_and_clean(); if skipped_count > 0 { warn!("Skipped {} invalid annotation(s)", skipped_count); for warning in &warnings { warn!("{}", warning); } } info!("COCO dataset parsed: {} images, {} annotations, {} categories", dataset.images.len(), dataset.annotations.len(), dataset.categories.len()); // Try to find the image directory automatically let json_dir = json_path.parent() .ok_or_else(|| "Could not determine JSON file directory".to_string())? .to_path_buf(); let image_dir = self.find_image_directory(&dataset, &json_dir)?; if let Some(dir) = image_dir { // Found the directory automatically self.set_image_directory(dataset, json_path, dir, images_with_invalid)?; Ok(true) } else { // Need to prompt user for directory self.current_json_path = Some(json_path); Ok(false) } } /// Set the image directory for a loaded dataset pub fn set_image_directory( &mut self, dataset: CocoDataset, json_path: PathBuf, image_directory: PathBuf, images_with_invalid: std::collections::HashSet, ) -> Result<(), String> { // Verify that at least some images exist in this directory let found = self.verify_images_in_directory(&dataset, &image_directory)?; if found == 0 { return Err(format!( "No images from the COCO dataset found in directory: {}", image_directory.display() )); } info!("Found {} images in directory: {}", found, image_directory.display()); // Build the annotation lookup map let annotation_map = dataset.build_image_annotation_map(); self.current_dataset = Some(LoadedDataset { dataset, image_directory, annotation_map, images_with_invalid_annos: images_with_invalid, }); self.current_json_path = Some(json_path); Ok(()) } /// Try to find the image directory automatically /// /// Checks: /// 1. Same directory as JSON file /// 2. "images" subdirectory /// 3. Common COCO directory names #[allow(dead_code)] fn find_image_directory( &self, dataset: &CocoDataset, json_dir: &std::path::Path, ) -> Result, String> { let candidates = vec![ json_dir.to_path_buf(), json_dir.join("images"), json_dir.join("img"), json_dir.join("data"), json_dir.join("train"), json_dir.join("val"), json_dir.join("test"), ]; // Get first few image filenames to check let test_filenames: Vec<_> = dataset.get_image_filenames() .into_iter() .take(5) .collect(); if test_filenames.is_empty() { return Ok(None); } // Check each candidate directory for candidate in candidates { if !candidate.exists() || !candidate.is_dir() { continue; } // Check if at least 2 test images exist in this directory let mut found = 0; for filename in &test_filenames { let image_path = candidate.join(filename); if image_path.exists() { found += 1; if found >= 2 { info!("Auto-detected image directory: {}", candidate.display()); return Ok(Some(candidate)); } } } } Ok(None) } /// Verify how many images from the dataset exist in the given directory fn verify_images_in_directory( &self, dataset: &CocoDataset, directory: &std::path::Path, ) -> Result { let mut found = 0; let filenames = dataset.get_image_filenames(); // Check first 20 images or all if fewer let check_count = filenames.len().min(20); for filename in filenames.iter().take(check_count) { let image_path = directory.join(filename); if image_path.exists() { found += 1; } } Ok(found) } /// Get annotations for a given image filename pub fn get_annotations(&self, filename: &str) -> Option<&Vec> { self.current_dataset.as_ref() .and_then(|ds| ds.annotation_map.get(filename)) } /// Check if annotations are currently loaded pub fn has_annotations(&self) -> bool { self.current_dataset.is_some() } /// Get the current image directory (if loaded) #[allow(dead_code)] pub fn get_image_directory(&self) -> Option<&PathBuf> { self.current_dataset.as_ref() .map(|ds| &ds.image_directory) } /// Get the current JSON path (if loaded) #[allow(dead_code)] pub fn get_json_path(&self) -> Option<&PathBuf> { self.current_json_path.as_ref() } /// Get dataset statistics #[allow(dead_code)] pub fn get_stats(&self) -> Option { self.current_dataset.as_ref().map(|ds| DatasetStats { num_images: ds.dataset.images.len(), num_annotations: ds.dataset.annotations.len(), num_categories: ds.dataset.categories.len(), }) } /// Check if the current image (by filename) had any invalid annotations pub fn has_invalid_annotations(&self, filename: &str) -> bool { if let Some(ds) = &self.current_dataset { // Find the image_id for this filename if let Some(image) = ds.dataset.images.iter().find(|img| img.file_name == filename) { return ds.images_with_invalid_annos.contains(&image.id); } } false } /// Clear the currently loaded dataset pub fn clear(&mut self) { self.current_dataset = None; self.current_json_path = None; info!("Cleared COCO annotations"); } } impl Default for AnnotationManager { fn default() -> Self { Self::new() } } /// Statistics about the loaded dataset #[derive(Debug, Clone)] #[allow(dead_code)] pub struct DatasetStats { pub num_images: usize, pub num_annotations: usize, pub num_categories: usize, } #[cfg(test)] mod tests { use super::*; #[test] fn test_annotation_manager_creation() { let manager = AnnotationManager::new(); assert!(!manager.has_annotations()); assert!(manager.get_annotations("test.jpg").is_none()); } } ================================================ FILE: src/coco/mod.rs ================================================ /// COCO dataset visualization module /// /// This module handles COCO format dataset loading, annotation management, /// and rendering of bounding boxes and segmentation masks. pub mod parser; pub mod annotation_manager; pub mod widget; pub mod overlay; pub mod rle_decoder; ================================================ FILE: src/coco/overlay/bbox_overlay.rs ================================================ /// Bounding box overlay rendering for COCO annotations /// /// This module renders bounding boxes using a custom WGPU shader. use iced_winit::core::{Element, Length, Color, Rectangle, Point, Vector}; use iced_winit::core::Theme as WinitTheme; use iced_wgpu::Renderer; use iced_widget::{Stack, container, text, column}; use iced_core::Border; use crate::app::Message; use crate::coco::parser::{ImageAnnotation, CocoSegmentation}; use crate::settings::CocoMaskRenderMode; use super::bbox_shader::BBoxShader; use super::polygon_shader::PolygonShader; use super::mask_shader::MaskShader; /// Get YOLO color for category ID (same as bbox_shader) fn get_category_color(category_id: u64) -> Color { let colors = [ [0.000, 0.447, 0.741], [0.850, 0.325, 0.098], [0.929, 0.694, 0.125], [0.494, 0.184, 0.556], [0.466, 0.674, 0.188], [0.301, 0.745, 0.933], [0.635, 0.078, 0.184], [0.300, 0.300, 0.300], [0.600, 0.600, 0.600], [1.000, 0.000, 0.000], [1.000, 0.500, 0.000], [0.749, 0.749, 0.000], [0.000, 1.000, 0.000], [0.000, 0.000, 1.000], [0.667, 0.000, 1.000], [0.333, 0.333, 0.000], [0.333, 0.667, 0.000], [0.333, 1.000, 0.000], [0.667, 0.333, 0.000], [0.667, 0.667, 0.000], [0.667, 1.000, 0.000], [1.000, 0.333, 0.000], [1.000, 0.667, 0.000], [1.000, 1.000, 0.000], [0.000, 0.333, 0.500], [0.000, 0.667, 0.500], [0.000, 1.000, 0.500], [0.333, 0.000, 0.500], [0.333, 0.333, 0.500], [0.333, 0.667, 0.500], [0.333, 1.000, 0.500], [0.667, 0.000, 0.500], [0.667, 0.333, 0.500], [0.667, 0.667, 0.500], [0.667, 1.000, 0.500], [1.000, 0.000, 0.500], [1.000, 0.333, 0.500], [1.000, 0.667, 0.500], [1.000, 1.000, 0.500], [0.000, 0.333, 1.000], [0.000, 0.667, 1.000], [0.000, 1.000, 1.000], [0.333, 0.000, 1.000], [0.333, 0.333, 1.000], [0.333, 0.667, 1.000], [0.333, 1.000, 1.000], [0.667, 0.000, 1.000], [0.667, 0.333, 1.000], [0.667, 0.667, 1.000], [0.667, 1.000, 1.000], [1.000, 0.000, 1.000], [1.000, 0.333, 1.000], [1.000, 0.667, 1.000], [0.333, 0.000, 0.000], [0.500, 0.000, 0.000], [0.667, 0.000, 0.000], [0.833, 0.000, 0.000], [1.000, 0.000, 0.000], [0.000, 0.167, 0.000], [0.000, 0.333, 0.000], [0.000, 0.500, 0.000], [0.000, 0.667, 0.000], [0.000, 0.833, 0.000], [0.000, 1.000, 0.000], [0.000, 0.000, 0.167], [0.000, 0.000, 0.333], [0.000, 0.000, 0.500], [0.000, 0.000, 0.667], [0.000, 0.000, 0.833], [0.000, 0.000, 1.000], [0.000, 0.000, 0.000], [0.143, 0.143, 0.143], [0.286, 0.286, 0.286], [0.429, 0.429, 0.429], [0.571, 0.571, 0.571], [0.714, 0.714, 0.714], [0.857, 0.857, 0.857], [0.000, 0.447, 0.741], [0.314, 0.717, 0.741], [0.500, 0.500, 0.000], ]; let idx = (category_id - 1) as usize % colors.len(); let rgb = colors[idx]; Color::from_rgb(rgb[0], rgb[1], rgb[2]) } /// Render bounding box and segmentation mask overlays for a list of annotations /// /// Uses custom WGPU shader for rendering actual bbox rectangles with text labels. /// Renders segmentation masks as semi-transparent filled polygons or pixel-perfect textures. /// Applies zoom transformation based on scale and offset parameters. pub fn render_bbox_overlay<'a>( annotations: &'a [ImageAnnotation], image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, show_bboxes: bool, show_masks: bool, has_invalid_annotations: bool, render_mode: CocoMaskRenderMode, disable_simplification: bool, ) -> Element<'a, Message, WinitTheme, Renderer> { if annotations.is_empty() { return container(iced_widget::Space::new(Length::Fill, Length::Fill)) .width(Length::Fill) .height(Length::Fill) .into(); } // Stack for layering visualizations let mut stack = Stack::new(); // Segmentation masks (rendered first, behind bboxes) if show_masks { let mask_element: Element<'a, Message, WinitTheme, Renderer> = match render_mode { CocoMaskRenderMode::Polygon => { // Polygon-based rendering (vector, scalable) PolygonShader::new(annotations.to_vec(), image_size, zoom_scale, zoom_offset, disable_simplification) .width(Length::Fill) .height(Length::Fill) .into() } CocoMaskRenderMode::Pixel => { // Pixel-based rendering (raster, exact) MaskShader::new(annotations.to_vec(), image_size, zoom_scale, zoom_offset) .width(Length::Fill) .height(Length::Fill) .into() } }; stack = stack.push(mask_element); } // Bbox rectangles if show_bboxes { let bbox_shader = BBoxShader::new(annotations.to_vec(), image_size, zoom_scale, zoom_offset) .width(Length::Fill) .height(Length::Fill); stack = stack.push(bbox_shader); // Create per-bbox label overlay let labels_overlay = BBoxLabels::into_element(annotations.to_vec(), image_size, zoom_scale, zoom_offset); stack = stack.push(labels_overlay); } // Category summary: count occurrences of each category let mut category_counts = std::collections::HashMap::new(); for annotation in annotations { *category_counts.entry(annotation.category_name.as_str()).or_insert(0) += 1; } // Sort by count descending, then by category name for stable ordering let mut sorted_categories: Vec<_> = category_counts.into_iter().collect(); sorted_categories.sort_by(|a, b| { b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)) // Primary: count desc, Secondary: name asc }); // Build category summary text let mut summary = column![]; // Add invalid annotation warning at the top if needed if has_invalid_annotations { summary = summary.push( text("WARNING: Invalid annotations skipped") .size(14) .style(|_theme| iced_widget::text::Style { color: Some(Color::from([1.0, 0.5, 0.0, 1.0])) // Orange warning }) ); } for (category, count) in sorted_categories { let label_text = format!("{} {}", count, category); summary = summary.push( text(label_text) .size(14) .style(|_theme| iced_widget::text::Style { color: Some(Color::from([1.0, 1.0, 0.0, 1.0])) }) ); } let summary_container = container(summary) .padding(8) .style(|_theme: &WinitTheme| iced_widget::container::Style { background: Some(Color::from([0.0, 0.0, 0.0, 0.7]).into()), border: Border { radius: 4.0.into(), width: 1.0, color: Color::from([1.0, 1.0, 0.0, 0.8]), }, ..iced_widget::container::Style::default() }); // Add category summary on top stack = stack.push(summary_container); stack.into() } /// Widget for rendering per-bbox labels struct BBoxLabels { annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, } impl BBoxLabels { fn into_element(annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector) -> Element<'static, Message, WinitTheme, Renderer> { let widget = Self { annotations, image_size, zoom_scale, zoom_offset, }; Element::new(widget) } } impl iced_core::Widget for BBoxLabels where R: iced_core::Renderer + iced_core::text::Renderer, { fn size(&self) -> iced_core::Size { iced_core::Size { width: Length::Fill, height: Length::Fill, } } fn layout( &self, _tree: &mut iced_core::widget::Tree, _renderer: &R, limits: &iced_core::layout::Limits, ) -> iced_core::layout::Node { iced_core::layout::atomic(limits, Length::Fill, Length::Fill) } fn draw( &self, _tree: &iced_core::widget::Tree, renderer: &mut R, _theme: &Theme, _style: &iced_core::renderer::Style, layout: iced_core::layout::Layout<'_>, _cursor: iced_core::mouse::Cursor, _viewport: &Rectangle, ) { use iced_core::text::Text; let bounds = layout.bounds(); // Calculate scaling (same as BBoxShader) let image_width = self.image_size.0 as f32; let image_height = self.image_size.1 as f32; let display_width = bounds.width; let display_height = bounds.height; // Base scale from ContentFit::Contain let width_ratio = display_width / image_width; let height_ratio = display_height / image_height; let base_scale = width_ratio.min(height_ratio); // Calculate zoomed image dimensions (changes with zoom) let zoomed_image_width = image_width * base_scale * self.zoom_scale; let zoomed_image_height = image_height * base_scale * self.zoom_scale; // Centering offset after zoom (changes as image grows/shrinks) let center_offset_x = (display_width - zoomed_image_width) / 2.0; let center_offset_y = (display_height - zoomed_image_height) / 2.0; // Draw label for each bbox for annotation in &self.annotations { // Scale bbox coordinates by base_scale and zoom_scale let scaled_bbox_x = annotation.bbox.x * base_scale * self.zoom_scale; let scaled_bbox_y = annotation.bbox.y * base_scale * self.zoom_scale; // Apply centering offset and pan offset (subtract offset like ImageShader does) let x = scaled_bbox_x + center_offset_x - self.zoom_offset.x + bounds.x; let y = scaled_bbox_y + center_offset_y - self.zoom_offset.y + bounds.y; // Get color for this category let bg_color = get_category_color(annotation.category_id); // Estimate text width (rough approximation) and scale with zoom let base_text_width = annotation.category_name.len() as f32 * 7.5; let text_width = base_text_width * self.zoom_scale; let base_label_height = 18.0; let label_height = base_label_height * self.zoom_scale; let padding = 4.0 * self.zoom_scale; // Position label just above the bbox let label_y = y - label_height - 2.0 * self.zoom_scale; // Draw colored background rectangle renderer.fill_quad( iced_core::renderer::Quad { bounds: Rectangle { x, y: label_y, width: text_width + padding * 2.0, height: label_height, }, border: Border { radius: 2.0.into(), width: 0.0, color: Color::TRANSPARENT, }, shadow: iced_core::Shadow::default(), }, bg_color, ); // Draw white text on colored background renderer.fill_text( Text { content: annotation.category_name.clone(), bounds: iced_core::Size::new(f32::INFINITY, label_height), size: (13.0 * self.zoom_scale).into(), line_height: iced_core::text::LineHeight::default(), font: renderer.default_font(), horizontal_alignment: iced_core::alignment::Horizontal::Left, vertical_alignment: iced_core::alignment::Vertical::Top, shaping: iced_core::text::Shaping::Basic, wrapping: iced_core::text::Wrapping::default(), }, Point::new(x + padding, label_y + 2.0 * self.zoom_scale), Color::WHITE, bounds, ); } } } impl<'a, Theme, R> From for Element<'a, Message, Theme, R> where R: iced_core::Renderer + iced_core::text::Renderer + 'a, { fn from(widget: BBoxLabels) -> Self { Element::new(widget) } } /// Widget for rendering segmentation masks struct SegmentationMasks { annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, } impl SegmentationMasks { #[allow(dead_code, clippy::new_ret_no_self)] fn new(annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector) -> Element<'static, Message, WinitTheme, Renderer> { let widget = Self { annotations, image_size, zoom_scale, zoom_offset, }; Element::new(widget) } } impl iced_core::Widget for SegmentationMasks where R: iced_core::Renderer, { fn size(&self) -> iced_core::Size { iced_core::Size { width: Length::Fill, height: Length::Fill, } } fn layout( &self, _tree: &mut iced_core::widget::Tree, _renderer: &R, limits: &iced_core::layout::Limits, ) -> iced_core::layout::Node { iced_core::layout::atomic(limits, Length::Fill, Length::Fill) } fn draw( &self, _tree: &iced_core::widget::Tree, renderer: &mut R, _theme: &Theme, _style: &iced_core::renderer::Style, layout: iced_core::layout::Layout<'_>, _cursor: iced_core::mouse::Cursor, _viewport: &Rectangle, ) { let bounds = layout.bounds(); // Calculate scaling (same as BBoxShader) let image_width = self.image_size.0 as f32; let image_height = self.image_size.1 as f32; let display_width = bounds.width; let display_height = bounds.height; // Base scale from ContentFit::Contain let width_ratio = display_width / image_width; let height_ratio = display_height / image_height; let base_scale = width_ratio.min(height_ratio); // Calculate zoomed image dimensions let zoomed_image_width = image_width * base_scale * self.zoom_scale; let zoomed_image_height = image_height * base_scale * self.zoom_scale; // Centering offset after zoom let center_offset_x = (display_width - zoomed_image_width) / 2.0; let center_offset_y = (display_height - zoomed_image_height) / 2.0; // Draw masks for each annotation for annotation in &self.annotations { if let Some(ref segmentation) = annotation.segmentation { let color = get_category_color(annotation.category_id); let mask_color = Color::from_rgba(color.r, color.g, color.b, 0.4); // 40% opacity match segmentation { CocoSegmentation::Polygon(polygons) => { // Render each polygon as a filled shape for polygon in polygons { self.draw_polygon( renderer, polygon, mask_color, bounds, base_scale, center_offset_x, center_offset_y, ); } } CocoSegmentation::Rle(_rle) => { // RLE rendering not yet implemented // Could decode RLE to polygon or render as pixel mask } } } } } } impl SegmentationMasks { #[allow(clippy::too_many_arguments)] fn draw_polygon( &self, renderer: &mut R, polygon: &[f32], color: Color, bounds: Rectangle, base_scale: f32, center_offset_x: f32, center_offset_y: f32, ) { // Polygon format: [x1, y1, x2, y2, x3, y3, ...] if polygon.len() < 6 { return; // Need at least 3 points (6 coordinates) } // Transform polygon vertices to screen coordinates let mut points = Vec::new(); for i in (0..polygon.len()).step_by(2) { if i + 1 >= polygon.len() { break; } let x = polygon[i]; let y = polygon[i + 1]; // Apply same transformation as bboxes let scaled_x = x * base_scale * self.zoom_scale; let scaled_y = y * base_scale * self.zoom_scale; let screen_x = scaled_x + center_offset_x - self.zoom_offset.x + bounds.x; let screen_y = scaled_y + center_offset_y - self.zoom_offset.y + bounds.y; points.push(Point::new(screen_x, screen_y)); } // Draw filled polygon using quad filling (approximate with triangles) if points.len() >= 3 { // Simple triangle fan from first point for i in 1..points.len() - 1 { self.draw_triangle(renderer, points[0], points[i], points[i + 1], color); } } } fn draw_triangle( &self, renderer: &mut R, p1: Point, p2: Point, p3: Point, color: Color, ) { // Calculate bounding box for the triangle let min_x = p1.x.min(p2.x).min(p3.x); let max_x = p1.x.max(p2.x).max(p3.x); let min_y = p1.y.min(p2.y).min(p3.y); let max_y = p1.y.max(p2.y).max(p3.y); // Fill the triangle using a filled quad (approximation) renderer.fill_quad( iced_core::renderer::Quad { bounds: Rectangle { x: min_x, y: min_y, width: max_x - min_x, height: max_y - min_y, }, border: Border::default(), shadow: iced_core::Shadow::default(), }, color, ); } } impl<'a, Theme, R> From for Element<'a, Message, Theme, R> where R: iced_core::Renderer + 'a, { fn from(widget: SegmentationMasks) -> Self { Element::new(widget) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_category_colors() { // Test that we get different colors for different indices let color0 = get_category_color(0); let color1 = get_category_color(1); assert_ne!(color0, color1); // Test wrapping let color10 = get_category_color(10); let color0_again = get_category_color(0); assert_eq!(color10, color0_again); } } ================================================ FILE: src/coco/overlay/bbox_shader.rs ================================================ /// BBox shader widget for rendering COCO bounding boxes /// /// Uses WGPU to draw colored rectangles with labels over images. use std::marker::PhantomData; use iced_core::{Color, Rectangle, Size, Length, Vector}; use iced_core::layout::{self, Layout}; use iced_core::mouse; use iced_core::renderer; use iced_core::widget::tree::Tree; use iced_winit::core::{Element, Widget}; use iced_widget::shader::{self, Viewport, Storage}; use iced_wgpu::{wgpu, primitive}; use wgpu::util::DeviceExt; use crate::coco::parser::ImageAnnotation; /// A shader widget for rendering bounding boxes pub struct BBoxShader { width: Length, height: Length, annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, _phantom: PhantomData, } impl BBoxShader { pub fn new(annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector) -> Self { Self { width: Length::Fill, height: Length::Fill, annotations, image_size, zoom_scale, zoom_offset, _phantom: PhantomData, } } pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } /// Calculate scaling from image coordinates to display coordinates #[allow(dead_code)] fn calculate_scale(&self, bounds: Rectangle) -> (f32, f32, f32, f32) { let image_width = self.image_size.0 as f32; let image_height = self.image_size.1 as f32; let display_width = bounds.width; let display_height = bounds.height; // ContentFit::Contain scaling let width_ratio = display_width / image_width; let height_ratio = display_height / image_height; let scale = width_ratio.min(height_ratio); let scaled_width = image_width * scale; let scaled_height = image_height * scale; // Center the image let offset_x = (display_width - scaled_width) / 2.0; let offset_y = (display_height - scaled_height) / 2.0; (scale, scale, offset_x, offset_y) } } /// Primitive for bbox rendering #[derive(Debug)] pub struct BBoxPrimitive { bounds: Rectangle, annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, } // Cache for vertex buffers created in prepare() struct BBoxBufferCache { buffers: Vec, } impl shader::Primitive for BBoxPrimitive { fn prepare( &self, device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat, storage: &mut Storage, _bounds: &Rectangle, viewport: &Viewport, ) { // Store viewport for use in render storage.store(viewport.clone()); // Create pipeline if needed if !storage.has::() { let pipeline = BBoxPipeline::new(device, format); storage.store(pipeline); } // Pre-create all vertex buffers for bboxes let viewport_size = viewport.physical_size(); let scale_factor = viewport.scale_factor() as f32; let image_width = self.image_size.0 as f32; let image_height = self.image_size.1 as f32; let display_width = self.bounds.width; let display_height = self.bounds.height; // Base scale from ContentFit::Contain let width_ratio = display_width / image_width; let height_ratio = display_height / image_height; let base_scale = width_ratio.min(height_ratio); // Calculate zoomed image dimensions (changes with zoom) let zoomed_image_width = image_width * base_scale * self.zoom_scale; let zoomed_image_height = image_height * base_scale * self.zoom_scale; // Centering offset after zoom (changes as image grows/shrinks) let center_offset_x = (display_width - zoomed_image_width) / 2.0; let center_offset_y = (display_height - zoomed_image_height) / 2.0; // log::debug!( // "BBox prepare: image=({},{}), bounds=({},{}) at ({:.1},{:.1}), base_scale={:.3}, zoom={:.3}, offset=({:.1},{:.1}), center=({:.1},{:.1})", // image_width, image_height, // display_width, display_height, // self.bounds.x, self.bounds.y, // base_scale, self.zoom_scale, // self.zoom_offset.x, self.zoom_offset.y, // center_offset_x, center_offset_y // ); let mut buffers = Vec::new(); for annotation in self.annotations.iter() { let color = get_category_color(annotation.category_id); // Scale bbox coordinates by base_scale and zoom_scale let scaled_bbox_x = annotation.bbox.x * base_scale * self.zoom_scale; let scaled_bbox_y = annotation.bbox.y * base_scale * self.zoom_scale; // Apply centering offset and pan offset // Add bounds.x/y to account for widget position in layout let x = (scaled_bbox_x + center_offset_x - self.zoom_offset.x + self.bounds.x) * scale_factor; let y = (scaled_bbox_y + center_offset_y - self.zoom_offset.y + self.bounds.y) * scale_factor; let width = annotation.bbox.width * base_scale * self.zoom_scale * scale_factor; let height = annotation.bbox.height * base_scale * self.zoom_scale * scale_factor; // Create 5 vertices for rectangle outline in NDC // Note: Invert y-axis because NDC has y=-1 at top, y=1 at bottom (opposite of screen coords) let vertices = [ BBoxVertex { position: [ (x / viewport_size.width as f32) * 2.0 - 1.0, 1.0 - (y / viewport_size.height as f32) * 2.0, // Inverted ], color: [color.r, color.g, color.b, color.a], }, BBoxVertex { position: [ ((x + width) / viewport_size.width as f32) * 2.0 - 1.0, 1.0 - (y / viewport_size.height as f32) * 2.0, // Inverted ], color: [color.r, color.g, color.b, color.a], }, BBoxVertex { position: [ ((x + width) / viewport_size.width as f32) * 2.0 - 1.0, 1.0 - ((y + height) / viewport_size.height as f32) * 2.0, // Inverted ], color: [color.r, color.g, color.b, color.a], }, BBoxVertex { position: [ (x / viewport_size.width as f32) * 2.0 - 1.0, 1.0 - ((y + height) / viewport_size.height as f32) * 2.0, // Inverted ], color: [color.r, color.g, color.b, color.a], }, BBoxVertex { position: [ (x / viewport_size.width as f32) * 2.0 - 1.0, 1.0 - (y / viewport_size.height as f32) * 2.0, // Inverted ], color: [color.r, color.g, color.b, color.a], }, ]; let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("BBox Vertex Buffer"), contents: bytemuck::cast_slice(&vertices), usage: wgpu::BufferUsages::VERTEX, }); buffers.push(buffer); } storage.store(BBoxBufferCache { buffers }); } fn render( &self, encoder: &mut wgpu::CommandEncoder, storage: &Storage, target: &wgpu::TextureView, clip_bounds: &Rectangle, ) { if self.annotations.is_empty() { return; } if let Some(pipeline) = storage.get::() { if let Some(cache) = storage.get::() { for buffer in &cache.buffers { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("BBox Render Pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); // Set scissor rectangle to clip rendering to bounds render_pass.set_scissor_rect( clip_bounds.x, clip_bounds.y, clip_bounds.width, clip_bounds.height, ); render_pass.set_pipeline(&pipeline.render_pipeline); render_pass.set_vertex_buffer(0, buffer.slice(..)); render_pass.draw(0..5, 0..1); } } } } } /// Vertex data for bbox rendering #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] struct BBoxVertex { position: [f32; 2], color: [f32; 4], } impl BBoxVertex { const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4]; fn desc() -> wgpu::VertexBufferLayout<'static> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, attributes: &Self::ATTRIBS, } } } /// Simple WGPU pipeline for drawing rectangles #[derive(Debug)] struct BBoxPipeline { render_pipeline: wgpu::RenderPipeline, } impl BBoxPipeline { fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("BBox Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("bbox_shader.wgsl").into()), }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("BBox Pipeline Layout"), bind_group_layouts: &[], push_constant_ranges: &[], }); let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("BBox Render Pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: "vs_main", buffers: &[BBoxVertex::desc()], }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::LineStrip, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, unclipped_depth: false, conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, }); Self { render_pipeline } } } /// Get color for category using YOLO/YOLOX color scheme /// Based on https://github.com/Megvii-BaseDetection/YOLOX/blob/main/yolox/utils/visualize.py fn get_category_color(category_id: u64) -> Color { // YOLO color palette for 80 COCO classes let colors = [ [0.000, 0.447, 0.741], [0.850, 0.325, 0.098], [0.929, 0.694, 0.125], [0.494, 0.184, 0.556], [0.466, 0.674, 0.188], [0.301, 0.745, 0.933], [0.635, 0.078, 0.184], [0.300, 0.300, 0.300], [0.600, 0.600, 0.600], [1.000, 0.000, 0.000], [1.000, 0.500, 0.000], [0.749, 0.749, 0.000], [0.000, 1.000, 0.000], [0.000, 0.000, 1.000], [0.667, 0.000, 1.000], [0.333, 0.333, 0.000], [0.333, 0.667, 0.000], [0.333, 1.000, 0.000], [0.667, 0.333, 0.000], [0.667, 0.667, 0.000], [0.667, 1.000, 0.000], [1.000, 0.333, 0.000], [1.000, 0.667, 0.000], [1.000, 1.000, 0.000], [0.000, 0.333, 0.500], [0.000, 0.667, 0.500], [0.000, 1.000, 0.500], [0.333, 0.000, 0.500], [0.333, 0.333, 0.500], [0.333, 0.667, 0.500], [0.333, 1.000, 0.500], [0.667, 0.000, 0.500], [0.667, 0.333, 0.500], [0.667, 0.667, 0.500], [0.667, 1.000, 0.500], [1.000, 0.000, 0.500], [1.000, 0.333, 0.500], [1.000, 0.667, 0.500], [1.000, 1.000, 0.500], [0.000, 0.333, 1.000], [0.000, 0.667, 1.000], [0.000, 1.000, 1.000], [0.333, 0.000, 1.000], [0.333, 0.333, 1.000], [0.333, 0.667, 1.000], [0.333, 1.000, 1.000], [0.667, 0.000, 1.000], [0.667, 0.333, 1.000], [0.667, 0.667, 1.000], [0.667, 1.000, 1.000], [1.000, 0.000, 1.000], [1.000, 0.333, 1.000], [1.000, 0.667, 1.000], [0.333, 0.000, 0.000], [0.500, 0.000, 0.000], [0.667, 0.000, 0.000], [0.833, 0.000, 0.000], [1.000, 0.000, 0.000], [0.000, 0.167, 0.000], [0.000, 0.333, 0.000], [0.000, 0.500, 0.000], [0.000, 0.667, 0.000], [0.000, 0.833, 0.000], [0.000, 1.000, 0.000], [0.000, 0.000, 0.167], [0.000, 0.000, 0.333], [0.000, 0.000, 0.500], [0.000, 0.000, 0.667], [0.000, 0.000, 0.833], [0.000, 0.000, 1.000], [0.000, 0.000, 0.000], [0.143, 0.143, 0.143], [0.286, 0.286, 0.286], [0.429, 0.429, 0.429], [0.571, 0.571, 0.571], [0.714, 0.714, 0.714], [0.857, 0.857, 0.857], [0.000, 0.447, 0.741], [0.314, 0.717, 0.741], [0.500, 0.500, 0.000], ]; let idx = (category_id - 1) as usize % colors.len(); // COCO category_id starts at 1 let rgb = colors[idx]; Color::from_rgb(rgb[0], rgb[1], rgb[2]) } // Implement Widget trait impl Widget for BBoxShader where R: primitive::Renderer, { fn size(&self) -> Size { Size { width: self.width, height: self.height, } } fn layout( &self, _tree: &mut Tree, _renderer: &R, limits: &layout::Limits, ) -> layout::Node { layout::atomic(limits, self.width, self.height) } fn draw( &self, _tree: &Tree, renderer: &mut R, _theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, _viewport: &Rectangle, ) { let bounds = layout.bounds(); if !self.annotations.is_empty() { let primitive = BBoxPrimitive { bounds, annotations: self.annotations.clone(), image_size: self.image_size, zoom_scale: self.zoom_scale, zoom_offset: self.zoom_offset, }; renderer.draw_primitive(bounds, primitive); } } } impl<'a, Message, Theme, R> From> for Element<'a, Message, Theme, R> where Message: 'a, R: primitive::Renderer + 'a, { fn from(shader: BBoxShader) -> Self { Element::new(shader) } } ================================================ FILE: src/coco/overlay/bbox_shader.wgsl ================================================ // BBox rectangle shader using vertex attributes struct VertexInput { @location(0) position: vec2, @location(1) color: vec4, }; struct VertexOutput { @builtin(position) position: vec4, @location(0) color: vec4, }; @vertex fn vs_main(input: VertexInput) -> VertexOutput { var out: VertexOutput; // Position is already in NDC from CPU-side conversion out.position = vec4(input.position, 0.0, 1.0); out.color = input.color; return out; } @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { return in.color; } ================================================ FILE: src/coco/overlay/mask_shader.rs ================================================ /// Pixel-perfect mask shader widget for rendering COCO RLE segmentation masks /// /// Uses WGPU texture-based rendering for exact pixel-level mask representation. use std::marker::PhantomData; use std::collections::HashMap; use iced_core::{Color, Rectangle, Size, Length, Vector}; use iced_core::layout::{self, Layout}; use iced_core::mouse; use iced_core::renderer; use iced_core::widget::tree::Tree; use iced_winit::core::{Element, Widget}; use iced_widget::shader::{self, Viewport, Storage}; use iced_wgpu::{wgpu, primitive}; use wgpu::util::DeviceExt; use crate::coco::parser::{ImageAnnotation, CocoSegmentation}; use crate::coco::rle_decoder; /// Maximum number of textures to cache in GPU memory const MAX_TEXTURE_CACHE_SIZE: usize = 200; /// A shader widget for rendering pixel-perfect masks pub struct MaskShader { width: Length, height: Length, annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, _phantom: PhantomData, } impl MaskShader { pub fn new(annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector) -> Self { Self { width: Length::Fill, height: Length::Fill, annotations, image_size, zoom_scale, zoom_offset, _phantom: PhantomData, } } pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } } /// Primitive for mask rendering #[derive(Debug)] pub struct MaskPrimitive { bounds: Rectangle, annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, } /// Cache for render resources with state tracking struct MaskBufferCache { quads: Vec, // Track the state that was used to create these quads cached_state: Option, } /// State used to determine if we need to recreate render resources #[derive(Clone, PartialEq)] struct CachedRenderState { annotation_ids: Vec, image_size: (u32, u32), bounds: (u32, u32), // width, height as u32 zoom_scale_bits: u32, // f32 as bits for equality zoom_offset_x_bits: u32, // f32 as bits for equality zoom_offset_y_bits: u32, // f32 as bits for equality } struct QuadRenderData { vertex_buffer: wgpu::Buffer, bind_group: wgpu::BindGroup, } /// GPU texture cache for RLE masks /// Maps annotation cache ID to texture handle type MaskTextureCache = HashMap; struct CachedTexture { #[allow(dead_code)] texture: wgpu::Texture, // Keep texture alive for the view view: wgpu::TextureView, width: u32, height: u32, last_used: std::time::Instant, } /// Helper to get a unique ID for caching (same as polygon shader for consistency) fn get_annotation_cache_id(ann: &ImageAnnotation) -> u64 { // Use annotation ID which is unique per annotation // This prevents cache collisions when different images have // annotations with identical bboxes and category_ids ann.id } impl shader::Primitive for MaskPrimitive { fn prepare( &self, device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat, storage: &mut Storage, _bounds: &Rectangle, viewport: &Viewport, ) { // Store viewport for use in render storage.store(viewport.clone()); // Create pipeline if needed if !storage.has::() { let pipeline = MaskPipeline::new(device, format); storage.store(pipeline); } // Get or create texture cache if !storage.has::() { storage.store(MaskTextureCache::new()); } // Get or create buffer cache if !storage.has::() { storage.store(MaskBufferCache { quads: Vec::new(), cached_state: None }); } // Calculate current state let viewport_size = viewport.physical_size(); let scale_factor = viewport.scale_factor() as f32; let current_state = CachedRenderState { annotation_ids: self.annotations.iter().map(|a| a.id).collect(), image_size: self.image_size, bounds: (self.bounds.width as u32, self.bounds.height as u32), zoom_scale_bits: self.zoom_scale.to_bits(), zoom_offset_x_bits: self.zoom_offset.x.to_bits(), zoom_offset_y_bits: self.zoom_offset.y.to_bits(), }; // Check if we can reuse cached quads let needs_rebuild = { let buffer_cache = storage.get::().unwrap(); buffer_cache.cached_state.as_ref() != Some(¤t_state) }; if !needs_rebuild { // Cached quads are still valid, no need to rebuild // log::debug!("MaskShader: Reusing cached quads for {} annotations", self.annotations.len()); return; } // log::debug!("MaskShader: Rebuilding quads for {} annotations (state changed)", self.annotations.len()); // Evict old textures if cache is full (before getting mutable reference) { let texture_cache = storage.get_mut::().unwrap(); if texture_cache.len() >= MAX_TEXTURE_CACHE_SIZE { evict_lru_textures(texture_cache, MAX_TEXTURE_CACHE_SIZE / 5); } } let image_width = self.image_size.0 as f32; let image_height = self.image_size.1 as f32; let display_width = self.bounds.width; let display_height = self.bounds.height; // Base scale from ContentFit::Contain let width_ratio = display_width / image_width; let height_ratio = display_height / image_height; let base_scale = width_ratio.min(height_ratio); // Calculate zoomed image dimensions let zoomed_image_width = image_width * base_scale * self.zoom_scale; let zoomed_image_height = image_height * base_scale * self.zoom_scale; // Centering offset after zoom let center_offset_x = (display_width - zoomed_image_width) / 2.0; let center_offset_y = (display_height - zoomed_image_height) / 2.0; // Get pipeline references we'll need (before mutable borrow) let bind_group_layout = &storage.get::().unwrap().bind_group_layout as *const _; let sampler = &storage.get::().unwrap().sampler as *const _; // Now get mutable reference to cache let texture_cache = storage.get_mut::().unwrap(); let mut quads = Vec::new(); // Safety: These pointers are valid for the lifetime of the function // and we're not modifying the pipeline while using them let bind_group_layout = unsafe { &*bind_group_layout }; let sampler = unsafe { &*sampler }; log::debug!("MaskShader: Processing {} annotations", self.annotations.len()); for annotation in self.annotations.iter() { // Only process RLE masks if let Some(CocoSegmentation::Rle(rle)) = &annotation.segmentation { log::debug!("MaskShader: Found RLE mask, size: {:?}", rle.size); let cache_id = get_annotation_cache_id(annotation); // Check if texture is already cached let texture_data = if let Some(cached) = texture_cache.get_mut(&cache_id) { // Update last used time cached.last_used = std::time::Instant::now(); cached } else { // Decode RLE and create texture let mut mask = rle_decoder::decode_rle(rle); if mask.is_empty() || rle.size.len() != 2 { log::warn!("MaskShader: Empty mask or invalid size, skipping"); continue; } // Convert 0/1 values to 0/255 for R8Unorm texture // R8Unorm maps 0 -> 0.0 and 255 -> 1.0 when sampled for pixel in mask.iter_mut() { *pixel = if *pixel > 0 { 255 } else { 0 }; } log::debug!("MaskShader: Decoded mask, {} bytes", mask.len()); let mask_height = rle.size[0]; let mask_width = rle.size[1]; // Create R8Unorm texture let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("Mask Texture"), size: wgpu::Extent3d { width: mask_width, height: mask_height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::R8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); // Upload mask data queue.write_texture( wgpu::ImageCopyTexture { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, &mask, wgpu::ImageDataLayout { offset: 0, bytes_per_row: Some(mask_width), rows_per_image: Some(mask_height), }, wgpu::Extent3d { width: mask_width, height: mask_height, depth_or_array_layers: 1, }, ); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let cached_texture = CachedTexture { texture, view, width: mask_width, height: mask_height, last_used: std::time::Instant::now(), }; texture_cache.insert(cache_id, cached_texture); texture_cache.get(&cache_id).unwrap() }; // Calculate mask dimensions (may differ from image size) let mask_width = texture_data.width as f32; let mask_height = texture_data.height as f32; // Check if scaling is needed let needs_scaling = (mask_width - image_width).abs() > 1.0 || (mask_height - image_height).abs() > 1.0; let (final_width, final_height) = if needs_scaling { (image_width, image_height) } else { (mask_width, mask_height) }; // Calculate bbox in screen coordinates (same as other shaders) let scaled_x = 0.0 * base_scale * self.zoom_scale; let scaled_y = 0.0 * base_scale * self.zoom_scale; let scaled_width = final_width * base_scale * self.zoom_scale; let scaled_height = final_height * base_scale * self.zoom_scale; let screen_x = (scaled_x + center_offset_x - self.zoom_offset.x + self.bounds.x) * scale_factor; let screen_y = (scaled_y + center_offset_y - self.zoom_offset.y + self.bounds.y) * scale_factor; let screen_width = scaled_width * scale_factor; let screen_height = scaled_height * scale_factor; // Create quad vertices in NDC let vertices = create_quad_vertices( screen_x, screen_y, screen_width, screen_height, viewport_size, ); let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Mask Quad Vertex Buffer"), contents: bytemuck::cast_slice(&vertices), usage: wgpu::BufferUsages::VERTEX, }); // Get category color let color = get_category_color(annotation.category_id); // Create uniform buffer with color let uniform_data = MaskUniforms { color: [color.r, color.g, color.b, color.a * 0.5], // Apply transparency }; let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Mask Uniform Buffer"), contents: bytemuck::cast_slice(&[uniform_data]), usage: wgpu::BufferUsages::UNIFORM, }); // Create bind group let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Mask Bind Group"), layout: bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: uniform_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&texture_data.view), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(sampler), }, ], }); quads.push(QuadRenderData { vertex_buffer, bind_group, }); log::debug!("MaskShader: Created quad for mask at screen ({}, {}), size ({}, {})", screen_x, screen_y, screen_width, screen_height); } } log::debug!("MaskShader: Total quads created: {}", quads.len()); storage.store(MaskBufferCache { quads, cached_state: Some(current_state), }); } fn render( &self, encoder: &mut wgpu::CommandEncoder, storage: &Storage, target: &wgpu::TextureView, clip_bounds: &Rectangle, ) { if self.annotations.is_empty() { return; } if let Some(pipeline) = storage.get::() { if let Some(cache) = storage.get::() { // Skip rendering if there are no quads (nothing to draw) if cache.quads.is_empty() { return; } for quad in &cache.quads { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Mask Render Pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); render_pass.set_scissor_rect( clip_bounds.x, clip_bounds.y, clip_bounds.width, clip_bounds.height, ); render_pass.set_pipeline(&pipeline.render_pipeline); render_pass.set_bind_group(0, &quad.bind_group, &[]); render_pass.set_vertex_buffer(0, quad.vertex_buffer.slice(..)); render_pass.draw(0..6, 0..1); // 6 vertices for 2 triangles } } } } } /// Create quad vertices (2 triangles) in NDC coordinates fn create_quad_vertices( x: f32, y: f32, width: f32, height: f32, viewport_size: Size, ) -> [MaskVertex; 6] { // Convert to NDC let x1_ndc = (x / viewport_size.width as f32) * 2.0 - 1.0; let y1_ndc = 1.0 - (y / viewport_size.height as f32) * 2.0; let x2_ndc = ((x + width) / viewport_size.width as f32) * 2.0 - 1.0; let y2_ndc = 1.0 - ((y + height) / viewport_size.height as f32) * 2.0; [ // Triangle 1 MaskVertex { position: [x1_ndc, y1_ndc], tex_coords: [0.0, 0.0], }, MaskVertex { position: [x2_ndc, y1_ndc], tex_coords: [1.0, 0.0], }, MaskVertex { position: [x1_ndc, y2_ndc], tex_coords: [0.0, 1.0], }, // Triangle 2 MaskVertex { position: [x2_ndc, y1_ndc], tex_coords: [1.0, 0.0], }, MaskVertex { position: [x2_ndc, y2_ndc], tex_coords: [1.0, 1.0], }, MaskVertex { position: [x1_ndc, y2_ndc], tex_coords: [0.0, 1.0], }, ] } /// Evict least-recently-used textures from cache fn evict_lru_textures(cache: &mut MaskTextureCache, count: usize) { let mut entries: Vec<_> = cache.iter().map(|(id, tex)| (*id, tex.last_used)).collect(); entries.sort_by_key(|(_, time)| *time); let to_remove: Vec<_> = entries.iter().take(count).map(|(id, _)| *id).collect(); for id in to_remove { cache.remove(&id); } log::debug!("Evicted {} textures from mask cache, {} remaining", count, cache.len()); } /// Vertex data for mask quad rendering #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] struct MaskVertex { position: [f32; 2], tex_coords: [f32; 2], } impl MaskVertex { const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2]; fn desc() -> wgpu::VertexBufferLayout<'static> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, attributes: &Self::ATTRIBS, } } } /// Uniform data for mask rendering #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] struct MaskUniforms { color: [f32; 4], } /// WGPU pipeline for rendering textured masks #[derive(Debug)] struct MaskPipeline { render_pipeline: wgpu::RenderPipeline, bind_group_layout: wgpu::BindGroupLayout, sampler: wgpu::Sampler, } impl MaskPipeline { fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Mask Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("mask_shader.wgsl").into()), }); // Create nearest-neighbor sampler for pixel-perfect rendering let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("Mask Sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Nearest, min_filter: wgpu::FilterMode::Nearest, mipmap_filter: wgpu::FilterMode::Nearest, ..Default::default() }); let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Mask Bind Group Layout"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Mask Pipeline Layout"), bind_group_layouts: &[&bind_group_layout], push_constant_ranges: &[], }); let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Mask Render Pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: "vs_main", buffers: &[MaskVertex::desc()], }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, unclipped_depth: false, conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, }); Self { render_pipeline, bind_group_layout, sampler, } } } /// Get color for category using YOLO/YOLOX color scheme fn get_category_color(category_id: u64) -> Color { let colors = [ [0.000, 0.447, 0.741], [0.850, 0.325, 0.098], [0.929, 0.694, 0.125], [0.494, 0.184, 0.556], [0.466, 0.674, 0.188], [0.301, 0.745, 0.933], [0.635, 0.078, 0.184], [0.300, 0.300, 0.300], [0.600, 0.600, 0.600], [1.000, 0.000, 0.000], [1.000, 0.500, 0.000], [0.749, 0.749, 0.000], [0.000, 1.000, 0.000], [0.000, 0.000, 1.000], [0.667, 0.000, 1.000], [0.333, 0.333, 0.000], [0.333, 0.667, 0.000], [0.333, 1.000, 0.000], [0.667, 0.333, 0.000], [0.667, 0.667, 0.000], [0.667, 1.000, 0.000], [1.000, 0.333, 0.000], [1.000, 0.667, 0.000], [1.000, 1.000, 0.000], [0.000, 0.333, 0.500], [0.000, 0.667, 0.500], [0.000, 1.000, 0.500], [0.333, 0.000, 0.500], [0.333, 0.333, 0.500], [0.333, 0.667, 0.500], [0.333, 1.000, 0.500], [0.667, 0.000, 0.500], [0.667, 0.333, 0.500], [0.667, 0.667, 0.500], [0.667, 1.000, 0.500], [1.000, 0.000, 0.500], [1.000, 0.333, 0.500], [1.000, 0.667, 0.500], [1.000, 1.000, 0.500], [0.000, 0.333, 1.000], [0.000, 0.667, 1.000], [0.000, 1.000, 1.000], [0.333, 0.000, 1.000], [0.333, 0.333, 1.000], [0.333, 0.667, 1.000], [0.333, 1.000, 1.000], [0.667, 0.000, 1.000], [0.667, 0.333, 1.000], [0.667, 0.667, 1.000], [0.667, 1.000, 1.000], [1.000, 0.000, 1.000], [1.000, 0.333, 1.000], [1.000, 0.667, 1.000], [0.333, 0.000, 0.000], [0.500, 0.000, 0.000], [0.667, 0.000, 0.000], [0.833, 0.000, 0.000], [1.000, 0.000, 0.000], [0.000, 0.167, 0.000], [0.000, 0.333, 0.000], [0.000, 0.500, 0.000], [0.000, 0.667, 0.000], [0.000, 0.833, 0.000], [0.000, 1.000, 0.000], [0.000, 0.000, 0.167], [0.000, 0.000, 0.333], [0.000, 0.000, 0.500], [0.000, 0.000, 0.667], [0.000, 0.000, 0.833], [0.000, 0.000, 1.000], [0.000, 0.000, 0.000], [0.143, 0.143, 0.143], [0.286, 0.286, 0.286], [0.429, 0.429, 0.429], [0.571, 0.571, 0.571], [0.714, 0.714, 0.714], [0.857, 0.857, 0.857], [0.000, 0.447, 0.741], [0.314, 0.717, 0.741], [0.500, 0.500, 0.000], ]; let idx = (category_id - 1) as usize % colors.len(); let rgb = colors[idx]; Color::from_rgb(rgb[0], rgb[1], rgb[2]) } // Implement Widget trait impl Widget for MaskShader where R: primitive::Renderer, { fn size(&self) -> Size { Size { width: self.width, height: self.height, } } fn layout( &self, _tree: &mut Tree, _renderer: &R, limits: &layout::Limits, ) -> layout::Node { layout::atomic(limits, self.width, self.height) } fn draw( &self, _tree: &Tree, renderer: &mut R, _theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, _viewport: &Rectangle, ) { let bounds = layout.bounds(); let primitive = MaskPrimitive { bounds, annotations: self.annotations.clone(), image_size: self.image_size, zoom_scale: self.zoom_scale, zoom_offset: self.zoom_offset, }; renderer.draw_primitive(bounds, primitive); } } impl<'a, Message, Theme, R> From> for Element<'a, Message, Theme, R> where Message: 'a, R: primitive::Renderer + 'a, { fn from(shader: MaskShader) -> Self { Element::new(shader) } } ================================================ FILE: src/coco/overlay/mask_shader.wgsl ================================================ // Pixel-level mask shader using textures struct VertexInput { @location(0) position: vec2, @location(1) tex_coords: vec2, }; struct VertexOutput { @builtin(position) position: vec4, @location(0) tex_coords: vec2, @location(1) color: vec4, }; struct Uniforms { color: vec4, } @group(0) @binding(0) var uniforms: Uniforms; @group(0) @binding(1) var mask_texture: texture_2d; @group(0) @binding(2) var mask_sampler: sampler; @vertex fn vs_main(input: VertexInput) -> VertexOutput { var output: VertexOutput; // Position is already in NDC from CPU-side conversion output.position = vec4(input.position, 0.0, 1.0); output.tex_coords = input.tex_coords; output.color = uniforms.color; return output; } @fragment fn fs_main(input: VertexOutput) -> @location(0) vec4 { // Sample binary mask texture let mask_value = textureSample(mask_texture, mask_sampler, input.tex_coords).r; // Discard background pixels if (mask_value < 0.5) { discard; } // Apply category color return input.color; } ================================================ FILE: src/coco/overlay/mod.rs ================================================ /// Overlay rendering for COCO annotations /// /// This module provides GPU-accelerated rendering of bounding boxes /// and segmentation masks using WGPU shaders. pub mod bbox_overlay; pub mod bbox_shader; pub mod polygon_shader; pub mod mask_shader; // Re-export the main overlay rendering function pub use bbox_overlay::render_bbox_overlay; ================================================ FILE: src/coco/overlay/polygon_shader.rs ================================================ /// Polygon mask shader widget for rendering COCO segmentation masks /// /// Uses WGPU to draw filled polygons with proper triangulation. use std::marker::PhantomData; use std::collections::HashMap; use iced_core::{Color, Rectangle, Size, Length, Vector}; use iced_core::layout::{self, Layout}; use iced_core::mouse; use iced_core::renderer; use iced_core::widget::tree::Tree; use iced_winit::core::{Element, Widget}; use iced_widget::shader::{self, Viewport, Storage}; use iced_wgpu::{wgpu, primitive}; use wgpu::util::DeviceExt; use crate::coco::parser::{ImageAnnotation, CocoSegmentation}; use crate::coco::rle_decoder; /// A shader widget for rendering segmentation masks pub struct PolygonShader { width: Length, height: Length, annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, disable_simplification: bool, _phantom: PhantomData, } impl PolygonShader { pub fn new(annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, disable_simplification: bool) -> Self { Self { width: Length::Fill, height: Length::Fill, annotations, image_size, zoom_scale, zoom_offset, disable_simplification, _phantom: PhantomData, } } pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } } /// Primitive for polygon rendering #[derive(Debug)] pub struct PolygonPrimitive { bounds: Rectangle, annotations: Vec, image_size: (u32, u32), zoom_scale: f32, zoom_offset: Vector, disable_simplification: bool, } // Cache for vertex buffers created in prepare() struct PolygonBufferCache { buffers: Vec<(wgpu::Buffer, u32)>, // (buffer, vertex_count) } // Cache for converted RLE polygons (annotation_id -> Vec>) type RlePolygonCache = HashMap>>; // Track simplification setting to invalidate cache when it changes struct SimplificationSetting { disable_simplification: bool, } // Helper to get a unique ID for caching fn get_annotation_cache_id(ann: &ImageAnnotation) -> u64 { // Use annotation ID which is unique per annotation // This prevents cache collisions when different images have // annotations with identical bboxes and category_ids ann.id } impl shader::Primitive for PolygonPrimitive { fn prepare( &self, device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat, storage: &mut Storage, _bounds: &Rectangle, viewport: &Viewport, ) { storage.store(viewport.clone()); // Create pipeline if needed if !storage.has::() { let pipeline = PolygonPipeline::new(device, format); storage.store(pipeline); } // Get or create RLE polygon cache if !storage.has::() { storage.store(RlePolygonCache::new()); } // Check if simplification setting changed and clear cache if needed let setting_changed = if let Some(prev_setting) = storage.get::() { prev_setting.disable_simplification != self.disable_simplification } else { false }; if setting_changed { log::debug!("Polygon simplification setting changed, clearing RLE polygon cache"); storage.store(RlePolygonCache::new()); } // Store current setting storage.store(SimplificationSetting { disable_simplification: self.disable_simplification, }); let rle_cache = storage.get_mut::().unwrap(); // Pre-create all vertex buffers for polygons let viewport_size = viewport.physical_size(); let scale_factor = viewport.scale_factor() as f32; let image_width = self.image_size.0 as f32; let image_height = self.image_size.1 as f32; let display_width = self.bounds.width; let display_height = self.bounds.height; // Base scale from ContentFit::Contain let width_ratio = display_width / image_width; let height_ratio = display_height / image_height; let base_scale = width_ratio.min(height_ratio); // Calculate zoomed image dimensions let zoomed_image_width = image_width * base_scale * self.zoom_scale; let zoomed_image_height = image_height * base_scale * self.zoom_scale; // Centering offset after zoom let center_offset_x = (display_width - zoomed_image_width) / 2.0; let center_offset_y = (display_height - zoomed_image_height) / 2.0; let mut buffers = Vec::new(); for annotation in self.annotations.iter() { if let Some(ref segmentation) = annotation.segmentation { let color = get_category_color(annotation.category_id); let mask_color = Color::from_rgba(color.r, color.g, color.b, 0.4); // 40% opacity match segmentation { CocoSegmentation::Polygon(polygons) => { for polygon in polygons { if polygon.len() < 6 { continue; // Need at least 3 points } // Transform polygon vertices to screen coordinates let mut screen_points = Vec::new(); for i in (0..polygon.len()).step_by(2) { if i + 1 >= polygon.len() { break; } let x = polygon[i]; let y = polygon[i + 1]; // Apply same transformation as bboxes let scaled_x = x * base_scale * self.zoom_scale; let scaled_y = y * base_scale * self.zoom_scale; let screen_x = (scaled_x + center_offset_x - self.zoom_offset.x + self.bounds.x) * scale_factor; let screen_y = (scaled_y + center_offset_y - self.zoom_offset.y + self.bounds.y) * scale_factor; screen_points.push((screen_x, screen_y)); } // Triangulate polygon using ear clipping if screen_points.len() >= 3 { // Convert to flat array for earcutr let mut coords: Vec = Vec::with_capacity(screen_points.len() * 2); for (x, y) in &screen_points { coords.push(*x as f64); coords.push(*y as f64); } // Perform ear clipping triangulation let triangles = earcutr::earcut(&coords, &[], 2); if let Ok(indices) = triangles { let mut vertices = Vec::new(); // Create vertices from triangulated indices for idx in indices.chunks(3) { if idx.len() == 3 { let p0 = screen_points[idx[0]]; let p1 = screen_points[idx[1]]; let p2 = screen_points[idx[2]]; // Convert to NDC let ndc0 = self.to_ndc(p0, viewport_size); let ndc1 = self.to_ndc(p1, viewport_size); let ndc2 = self.to_ndc(p2, viewport_size); vertices.push(PolygonVertex { position: ndc0, color: [mask_color.r, mask_color.g, mask_color.b, mask_color.a], }); vertices.push(PolygonVertex { position: ndc1, color: [mask_color.r, mask_color.g, mask_color.b, mask_color.a], }); vertices.push(PolygonVertex { position: ndc2, color: [mask_color.r, mask_color.g, mask_color.b, mask_color.a], }); } } if !vertices.is_empty() { let vertex_count = vertices.len() as u32; let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Polygon Vertex Buffer"), contents: bytemuck::cast_slice(&vertices), usage: wgpu::BufferUsages::VERTEX, }); buffers.push((buffer, vertex_count)); } } } } } CocoSegmentation::Rle(rle) => { // Convert RLE to polygons (with caching and coordinate scaling) let cache_id = get_annotation_cache_id(annotation); let polygons_from_rle = if let Some(cached) = rle_cache.get(&cache_id) { // Use cached polygons (already in image coordinates) cached.clone() } else { // Decode and cache let mask = rle_decoder::decode_rle(rle); if !mask.is_empty() && rle.size.len() == 2 { let mask_height = rle.size[0] as f32; let mask_width = rle.size[1] as f32; // Use epsilon based on user setting // When simplification is disabled, use 0.0 (no simplification) // When enabled, use 1.0 (minimal simplification) let simplify_epsilon = if self.disable_simplification { 0.0 } else { 1.0 }; let polygons = rle_decoder::mask_to_polygons( &mask, mask_width as usize, mask_height as usize, simplify_epsilon, ); // Check if RLE size matches image size let needs_scaling = (mask_width - image_width).abs() > 1.0 || (mask_height - image_height).abs() > 1.0; let final_polygons: Vec> = if needs_scaling { // Scale polygons from RLE coordinates to image coordinates let x_scale = image_width / mask_width; let y_scale = image_height / mask_height; log::warn!("RLE size {}x{} doesn't match image {}x{}, scaling", mask_width, mask_height, image_width, image_height); polygons .into_iter() .map(|poly| { poly.into_iter() .map(|(x, y)| (x * x_scale, y * y_scale)) .collect() }) .collect() } else { // No scaling needed, RLE is already in image coordinates polygons }; // Debug: Log polygon count let total_points: usize = final_polygons.iter().map(|p| p.len()).sum(); log::debug!("RLE decoded: {} polygons, {} total points", final_polygons.len(), total_points); rle_cache.insert(cache_id, final_polygons.clone()); final_polygons } else { Vec::new() } }; // Render polygons using same logic as Polygon branch for polygon_points in polygons_from_rle { if polygon_points.len() < 3 { continue; } // Convert to flat Vec format like regular polygons let polygon_flat: Vec = polygon_points .into_iter() .flat_map(|(x, y)| vec![x, y]) .collect(); // Transform polygon vertices to screen coordinates let mut screen_points = Vec::new(); for i in (0..polygon_flat.len()).step_by(2) { if i + 1 >= polygon_flat.len() { break; } let x = polygon_flat[i]; let y = polygon_flat[i + 1]; // Apply same transformation as regular polygons let scaled_x = x * base_scale * self.zoom_scale; let scaled_y = y * base_scale * self.zoom_scale; let screen_x = (scaled_x + center_offset_x - self.zoom_offset.x + self.bounds.x) * scale_factor; let screen_y = (scaled_y + center_offset_y - self.zoom_offset.y + self.bounds.y) * scale_factor; screen_points.push((screen_x, screen_y)); } // Triangulate and create buffers if screen_points.len() >= 3 { let mut coords: Vec = Vec::with_capacity(screen_points.len() * 2); for (x, y) in &screen_points { coords.push(*x as f64); coords.push(*y as f64); } let triangles = earcutr::earcut(&coords, &[], 2); if let Ok(indices) = triangles { let mut vertices = Vec::new(); for idx in indices.chunks(3) { if idx.len() == 3 { let p0 = screen_points[idx[0]]; let p1 = screen_points[idx[1]]; let p2 = screen_points[idx[2]]; let ndc0 = self.to_ndc(p0, viewport_size); let ndc1 = self.to_ndc(p1, viewport_size); let ndc2 = self.to_ndc(p2, viewport_size); vertices.push(PolygonVertex { position: ndc0, color: [mask_color.r, mask_color.g, mask_color.b, mask_color.a], }); vertices.push(PolygonVertex { position: ndc1, color: [mask_color.r, mask_color.g, mask_color.b, mask_color.a], }); vertices.push(PolygonVertex { position: ndc2, color: [mask_color.r, mask_color.g, mask_color.b, mask_color.a], }); } } if !vertices.is_empty() { let vertex_count = vertices.len() as u32; let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Polygon Vertex Buffer (RLE)"), contents: bytemuck::cast_slice(&vertices), usage: wgpu::BufferUsages::VERTEX, }); buffers.push((buffer, vertex_count)); } } } } } } } } storage.store(PolygonBufferCache { buffers }); } fn render( &self, encoder: &mut wgpu::CommandEncoder, storage: &Storage, target: &wgpu::TextureView, clip_bounds: &Rectangle, ) { if let Some(pipeline) = storage.get::() { if let Some(cache) = storage.get::() { for (buffer, vertex_count) in &cache.buffers { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Polygon Render Pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, }); // Set scissor rectangle to clip rendering to bounds render_pass.set_scissor_rect( clip_bounds.x, clip_bounds.y, clip_bounds.width, clip_bounds.height, ); render_pass.set_pipeline(&pipeline.render_pipeline); render_pass.set_vertex_buffer(0, buffer.slice(..)); render_pass.draw(0..*vertex_count, 0..1); } } } } } impl PolygonPrimitive { fn to_ndc(&self, point: (f32, f32), viewport_size: iced_core::Size) -> [f32; 2] { [ (point.0 / viewport_size.width as f32) * 2.0 - 1.0, 1.0 - (point.1 / viewport_size.height as f32) * 2.0, // Inverted Y ] } } /// Vertex data for polygon rendering #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] struct PolygonVertex { position: [f32; 2], color: [f32; 4], } impl PolygonVertex { const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4]; fn desc() -> wgpu::VertexBufferLayout<'static> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, attributes: &Self::ATTRIBS, } } } /// WGPU pipeline for drawing filled polygons #[derive(Debug)] struct PolygonPipeline { render_pipeline: wgpu::RenderPipeline, } impl PolygonPipeline { fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Polygon Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("polygon_shader.wgsl").into()), }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Polygon Pipeline Layout"), bind_group_layouts: &[], push_constant_ranges: &[], }); let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Polygon Render Pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: "vs_main", buffers: &[PolygonVertex::desc()], }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { format, blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: None, polygon_mode: wgpu::PolygonMode::Fill, unclipped_depth: false, conservative: false, }, depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview: None, }); Self { render_pipeline } } } /// Get color for category using YOLO/YOLOX color scheme fn get_category_color(category_id: u64) -> Color { let colors = [ [0.000, 0.447, 0.741], [0.850, 0.325, 0.098], [0.929, 0.694, 0.125], [0.494, 0.184, 0.556], [0.466, 0.674, 0.188], [0.301, 0.745, 0.933], [0.635, 0.078, 0.184], [0.300, 0.300, 0.300], [0.600, 0.600, 0.600], [1.000, 0.000, 0.000], [1.000, 0.500, 0.000], [0.749, 0.749, 0.000], [0.000, 1.000, 0.000], [0.000, 0.000, 1.000], [0.667, 0.000, 1.000], [0.333, 0.333, 0.000], [0.333, 0.667, 0.000], [0.333, 1.000, 0.000], [0.667, 0.333, 0.000], [0.667, 0.667, 0.000], [0.667, 1.000, 0.000], [1.000, 0.333, 0.000], [1.000, 0.667, 0.000], [1.000, 1.000, 0.000], [0.000, 0.333, 0.500], [0.000, 0.667, 0.500], [0.000, 1.000, 0.500], [0.333, 0.000, 0.500], [0.333, 0.333, 0.500], [0.333, 0.667, 0.500], [0.333, 1.000, 0.500], [0.667, 0.000, 0.500], [0.667, 0.333, 0.500], [0.667, 0.667, 0.500], [0.667, 1.000, 0.500], [1.000, 0.000, 0.500], [1.000, 0.333, 0.500], [1.000, 0.667, 0.500], [1.000, 1.000, 0.500], [0.000, 0.333, 1.000], [0.000, 0.667, 1.000], [0.000, 1.000, 1.000], [0.333, 0.000, 1.000], [0.333, 0.333, 1.000], [0.333, 0.667, 1.000], [0.333, 1.000, 1.000], [0.667, 0.000, 1.000], [0.667, 0.333, 1.000], [0.667, 0.667, 1.000], [0.667, 1.000, 1.000], [1.000, 0.000, 1.000], [1.000, 0.333, 1.000], [1.000, 0.667, 1.000], [0.333, 0.000, 0.000], [0.500, 0.000, 0.000], [0.667, 0.000, 0.000], [0.833, 0.000, 0.000], [1.000, 0.000, 0.000], [0.000, 0.167, 0.000], [0.000, 0.333, 0.000], [0.000, 0.500, 0.000], [0.000, 0.667, 0.000], [0.000, 0.833, 0.000], [0.000, 1.000, 0.000], [0.000, 0.000, 0.167], [0.000, 0.000, 0.333], [0.000, 0.000, 0.500], [0.000, 0.000, 0.667], [0.000, 0.000, 0.833], [0.000, 0.000, 1.000], [0.000, 0.000, 0.000], [0.143, 0.143, 0.143], [0.286, 0.286, 0.286], [0.429, 0.429, 0.429], [0.571, 0.571, 0.571], [0.714, 0.714, 0.714], [0.857, 0.857, 0.857], [0.000, 0.447, 0.741], [0.314, 0.717, 0.741], [0.500, 0.500, 0.000], ]; let idx = (category_id - 1) as usize % colors.len(); let rgb = colors[idx]; Color::from_rgb(rgb[0], rgb[1], rgb[2]) } // Implement Widget trait impl Widget for PolygonShader where R: primitive::Renderer, { fn size(&self) -> Size { Size { width: self.width, height: self.height, } } fn layout( &self, _tree: &mut Tree, _renderer: &R, limits: &layout::Limits, ) -> layout::Node { layout::atomic(limits, self.width, self.height) } fn draw( &self, _tree: &Tree, renderer: &mut R, _theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, _viewport: &Rectangle, ) { let bounds = layout.bounds(); let primitive = PolygonPrimitive { bounds, annotations: self.annotations.clone(), image_size: self.image_size, zoom_scale: self.zoom_scale, zoom_offset: self.zoom_offset, disable_simplification: self.disable_simplification, }; renderer.draw_primitive(bounds, primitive); } } impl<'a, Message, Theme, R> From> for Element<'a, Message, Theme, R> where Message: 'a, R: primitive::Renderer + 'a, { fn from(shader: PolygonShader) -> Self { Element::new(shader) } } ================================================ FILE: src/coco/overlay/polygon_shader.wgsl ================================================ // Polygon mask shader for filled shapes struct VertexInput { @location(0) position: vec2, @location(1) color: vec4, }; struct VertexOutput { @builtin(position) position: vec4, @location(0) color: vec4, }; @vertex fn vs_main(input: VertexInput) -> VertexOutput { var out: VertexOutput; // Position is already in NDC from CPU-side conversion out.position = vec4(input.position, 0.0, 1.0); out.color = input.color; return out; } @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { return in.color; } ================================================ FILE: src/coco/parser.rs ================================================ /// COCO dataset JSON parser /// /// This module parses COCO format annotation files. /// Format specification: https://cocodataset.org/#format-data use std::collections::HashMap; use std::path::PathBuf; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CocoDataset { pub images: Vec, pub annotations: Vec, pub categories: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CocoImage { pub id: u64, pub file_name: String, #[serde(default)] pub width: u32, #[serde(default)] pub height: u32, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CocoAnnotation { pub id: u64, pub image_id: u64, pub category_id: u64, pub bbox: Vec, // [x, y, width, height] in COCO format #[serde(default)] pub segmentation: Option, #[serde(default)] pub area: f32, #[serde(default)] pub iscrowd: u8, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum CocoSegmentation { Polygon(Vec>), // List of polygons Rle(CocoRLE), // Run-length encoding } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CocoRLE { pub counts: Vec, pub size: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CocoCategory { pub id: u64, pub name: String, #[serde(default)] pub supercategory: String, } impl CocoDataset { /// Parse COCO JSON from a file pub fn from_file(path: &PathBuf) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read COCO file: {}", e))?; Self::from_str(&content) } /// Parse COCO JSON from a string pub fn from_str(content: &str) -> Result { serde_json::from_str(content) .map_err(|e| format!("Failed to parse COCO JSON: {}", e)) } /// Validate that this looks like a COCO dataset and filter out invalid annotations /// Returns (skipped_count, warnings, images_with_invalid_annos) pub fn validate_and_clean(&mut self) -> (usize, Vec, std::collections::HashSet) { let mut warnings = Vec::new(); let mut images_with_invalid = std::collections::HashSet::new(); if self.images.is_empty() { warnings.push("COCO dataset has no images".to_string()); } if self.categories.is_empty() { warnings.push("COCO dataset has no categories".to_string()); } // Check that annotations reference valid image_ids and category_ids let image_ids: std::collections::HashSet<_> = self.images.iter().map(|img| img.id).collect(); let category_ids: std::collections::HashSet<_> = self.categories.iter().map(|cat| cat.id).collect(); let original_count = self.annotations.len(); // Filter out invalid annotations self.annotations.retain(|ann| { if !image_ids.contains(&ann.image_id) { warnings.push(format!( "Skipping annotation {}: references non-existent image_id {}", ann.id, ann.image_id )); images_with_invalid.insert(ann.image_id); return false; } if !category_ids.contains(&ann.category_id) { warnings.push(format!( "Skipping annotation {}: references non-existent category_id {}", ann.id, ann.category_id )); images_with_invalid.insert(ann.image_id); return false; } if ann.bbox.len() != 4 { warnings.push(format!( "Skipping annotation {}: invalid bbox format (expected 4 values, got {})", ann.id, ann.bbox.len() )); images_with_invalid.insert(ann.image_id); return false; } true }); let skipped_count = original_count - self.annotations.len(); (skipped_count, warnings, images_with_invalid) } /// Check if JSON content looks like a COCO dataset (quick detection) pub fn is_coco_format(content: &str) -> bool { // Try to parse as JSON and check for required keys if let Ok(value) = serde_json::from_str::(content) { if let Some(obj) = value.as_object() { return obj.contains_key("images") && obj.contains_key("annotations") && obj.contains_key("categories"); } } false } /// Build a lookup map from filename to annotations /// Keeps segmentations in their original format (RLE or Polygon) pub fn build_image_annotation_map(&self) -> HashMap> { let mut map: HashMap> = HashMap::new(); // Create category lookup let category_map: HashMap = self.categories.iter().map(|cat| (cat.id, cat)).collect(); // Create image lookup let image_map: HashMap = self.images.iter().map(|img| (img.id, img)).collect(); // Group annotations by image for ann in &self.annotations { if let Some(image) = image_map.get(&ann.image_id) { let category_name = category_map .get(&ann.category_id) .map(|cat| cat.name.clone()) .unwrap_or_else(|| format!("Unknown ({})", ann.category_id)); let image_ann = ImageAnnotation { id: ann.id, bbox: BoundingBox { x: ann.bbox[0], y: ann.bbox[1], width: ann.bbox[2], height: ann.bbox[3], }, category_id: ann.category_id, category_name, segmentation: ann.segmentation.clone(), }; map.entry(image.file_name.clone()) .or_default() .push(image_ann); } } map } /// Get list of all image filenames in the dataset pub fn get_image_filenames(&self) -> Vec { self.images.iter().map(|img| img.file_name.clone()).collect() } } /// Simplified annotation structure for rendering #[derive(Debug, Clone)] pub struct ImageAnnotation { pub id: u64, // Annotation ID for cache key uniqueness pub bbox: BoundingBox, pub category_id: u64, pub category_name: String, pub segmentation: Option, } #[derive(Debug, Clone, Copy)] pub struct BoundingBox { pub x: f32, pub y: f32, pub width: f32, pub height: f32, } impl BoundingBox { /// Convert COCO bbox (x, y, w, h) to top-left and bottom-right corners pub fn to_corners(self) -> (f32, f32, f32, f32) { (self.x, self.y, self.x + self.width, self.y + self.height) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_coco_detection() { let valid_coco = r#"{ "images": [], "annotations": [], "categories": [] }"#; assert!(CocoDataset::is_coco_format(valid_coco)); let invalid = r#"{"foo": "bar"}"#; assert!(!CocoDataset::is_coco_format(invalid)); } #[test] fn test_coco_parsing() { let coco_json = r#"{ "images": [ {"id": 1, "file_name": "test.jpg", "width": 640, "height": 480} ], "annotations": [ { "id": 1, "image_id": 1, "category_id": 1, "bbox": [10.0, 20.0, 100.0, 200.0], "area": 20000.0, "iscrowd": 0 } ], "categories": [ {"id": 1, "name": "person", "supercategory": "human"} ] }"#; let mut dataset = CocoDataset::from_str(coco_json).unwrap(); let (skipped_count, _warnings, images_with_invalid) = dataset.validate_and_clean(); assert_eq!(skipped_count, 0); assert!(images_with_invalid.is_empty()); assert_eq!(dataset.images.len(), 1); assert_eq!(dataset.annotations.len(), 1); assert_eq!(dataset.categories.len(), 1); } } ================================================ FILE: src/coco/rle_decoder.rs ================================================ /// RLE (Run-Length Encoding) decoder for COCO segmentation masks /// /// This module decodes COCO RLE format masks and converts them to polygons for rendering. /// RLE format: {"size": [height, width], "counts": [run1, run2, ...]} /// Counts alternate between 0s and 1s, starting with 0s. /// IMPORTANT: COCO RLE uses COLUMN-MAJOR (Fortran) order! use crate::coco::parser::CocoRLE; /// Decode RLE to binary mask /// COCO RLE uses column-major order (Fortran-style), meaning it fills column-by-column pub fn decode_rle(rle: &CocoRLE) -> Vec { if rle.size.len() != 2 { return Vec::new(); } let height = rle.size[0] as usize; let width = rle.size[1] as usize; let total_pixels = height * width; // Create mask in row-major order for easier access let mut mask = vec![0u8; total_pixels]; let mut col = 0; let mut row = 0; let mut value = 0u8; // Start with 0 (background) for &count in &rle.counts { let count = count as usize; // Fill pixels with current value in column-major order for _ in 0..count { if col < width && row < height { // Convert column-major to row-major indexing let idx = row + col * height; if idx < total_pixels { // Store in row-major format for rendering mask[row * width + col] = value; } // Move to next position in column-major order row += 1; if row >= height { row = 0; col += 1; } } else { break; } } // Alternate between 0 and 1 value = 1 - value; } mask } /// Find contours in a binary mask using a simple marching squares algorithm /// Returns a list of polygons (each polygon is a list of (x, y) coordinates) pub fn mask_to_polygons(mask: &[u8], width: usize, height: usize, simplify_epsilon: f32) -> Vec> { if mask.is_empty() || width == 0 || height == 0 { return Vec::new(); } let mut visited = vec![false; mask.len()]; let mut polygons = Vec::new(); // Find all contours for y in 0..height { for x in 0..width { let idx = y * width + x; // Look for foreground pixels that haven't been visited if mask[idx] > 0 && !visited[idx] { // Trace contour starting from this pixel if let Some(contour) = trace_contour(mask, width, height, x, y, &mut visited) { if contour.len() >= 3 { // Simplify polygon using Douglas-Peucker algorithm let simplified = simplify_polygon(&contour, simplify_epsilon); if simplified.len() >= 3 { polygons.push(simplified); } } } } } } polygons } /// Trace contour starting from a foreground pixel using Moore neighborhood tracing fn trace_contour( mask: &[u8], width: usize, height: usize, start_x: usize, start_y: usize, visited: &mut [bool], ) -> Option> { let mut contour = Vec::new(); // Find the boundary by checking if any neighbor is background let idx = start_y * width + start_x; if !is_boundary_pixel(mask, width, height, start_x, start_y) { // Mark interior pixels as visited but don't trace them visited[idx] = true; return None; } // Moore neighborhood (8-connected): N, NE, E, SE, S, SW, W, NW let dx: [i32; 8] = [0, 1, 1, 1, 0, -1, -1, -1]; let dy: [i32; 8] = [-1, -1, 0, 1, 1, 1, 0, -1]; let mut x = start_x as i32; let mut y = start_y as i32; let mut dir = 0; // Start looking north let start_idx = idx; visited[start_idx] = true; contour.push((x as f32, y as f32)); let max_iterations = width * height; // Prevent infinite loops let mut iterations = 0; loop { iterations += 1; if iterations > max_iterations { break; } // Look for next boundary pixel in clockwise direction let mut found = false; for i in 0..8 { let check_dir = (dir + i) % 8; let nx = x + dx[check_dir]; let ny = y + dy[check_dir]; if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 { let nidx = (ny as usize) * width + (nx as usize); if mask[nidx] > 0 { // Found next foreground pixel x = nx; y = ny; dir = (check_dir + 6) % 8; // Turn left for next search // Check if we're back at start if nidx == start_idx && contour.len() > 2 { return Some(contour); } if !visited[nidx] || contour.len() < 3 { visited[nidx] = true; // Add point if it changes direction enough if should_add_point(&contour, x as f32, y as f32) { contour.push((x as f32, y as f32)); } } found = true; break; } } } if !found { break; // No next pixel found, end contour } } if contour.len() >= 3 { Some(contour) } else { None } } /// Check if a pixel is on the boundary (has at least one background neighbor) fn is_boundary_pixel(mask: &[u8], width: usize, height: usize, x: usize, y: usize) -> bool { // Check 4-connected neighbors let neighbors = [ (x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1), ]; for (nx, ny) in neighbors { if nx >= width || ny >= height { return true; // Edge of image } let nidx = ny * width + nx; if mask[nidx] == 0 { return true; // Has background neighbor } } false } /// Check if a point should be added to contour (reduces redundant collinear points) fn should_add_point(contour: &[(f32, f32)], x: f32, y: f32) -> bool { if contour.len() < 2 { return true; } let p0 = contour[contour.len() - 2]; let p1 = contour[contour.len() - 1]; let p2 = (x, y); // Check if points are collinear let dx1 = p1.0 - p0.0; let dy1 = p1.1 - p0.1; let dx2 = p2.0 - p1.0; let dy2 = p2.1 - p1.1; // Cross product let cross = dx1 * dy2 - dy1 * dx2; // If cross product is near zero, points are collinear cross.abs() > 0.5 } /// Simplify polygon using Douglas-Peucker algorithm fn simplify_polygon(points: &[(f32, f32)], epsilon: f32) -> Vec<(f32, f32)> { if points.len() <= 2 { return points.to_vec(); } let mut result = Vec::new(); douglas_peucker_recursive(points, epsilon, &mut result); result } fn douglas_peucker_recursive(points: &[(f32, f32)], epsilon: f32, result: &mut Vec<(f32, f32)>) { if points.is_empty() { return; } if points.len() <= 2 { result.extend_from_slice(points); return; } // Find the point with maximum distance from line segment let start = points[0]; let end = points[points.len() - 1]; let mut max_dist = 0.0f32; let mut max_idx = 0; for (i, &point) in points.iter().enumerate().skip(1).take(points.len() - 2) { let dist = perpendicular_distance(point, start, end); if dist > max_dist { max_dist = dist; max_idx = i; } } if max_dist > epsilon { // Recursively simplify douglas_peucker_recursive(&points[..=max_idx], epsilon, result); result.pop(); // Remove duplicate point douglas_peucker_recursive(&points[max_idx..], epsilon, result); } else { // All points are close enough, just keep endpoints result.push(start); result.push(end); } } /// Calculate perpendicular distance from point to line segment fn perpendicular_distance(point: (f32, f32), line_start: (f32, f32), line_end: (f32, f32)) -> f32 { let dx = line_end.0 - line_start.0; let dy = line_end.1 - line_start.1; let norm = (dx * dx + dy * dy).sqrt(); if norm < 1e-6 { // Line segment is actually a point let pdx = point.0 - line_start.0; let pdy = point.1 - line_start.1; return (pdx * pdx + pdy * pdy).sqrt(); } // Calculate perpendicular distance using cross product let cross = (point.0 - line_start.0) * dy - (point.1 - line_start.1) * dx; cross.abs() / norm } #[cfg(test)] mod tests { use super::*; #[test] fn test_rle_decode() { // Simple 3x3 mask with center pixel set let rle = CocoRLE { size: vec![3, 3], counts: vec![4, 1, 4], // 4 zeros, 1 one, 4 zeros }; let mask = decode_rle(&rle); assert_eq!(mask.len(), 9); assert_eq!(mask[4], 1); // Center pixel should be set assert_eq!(mask[0], 0); assert_eq!(mask[8], 0); } #[test] fn test_perpendicular_distance() { let point = (1.0, 1.0); let line_start = (0.0, 0.0); let line_end = (2.0, 0.0); let dist = perpendicular_distance(point, line_start, line_end); assert!((dist - 1.0).abs() < 0.01); } #[test] fn test_simplify_polygon() { // Collinear points should be simplified let points = vec![ (0.0, 0.0), (1.0, 0.0), (2.0, 0.0), (3.0, 0.0), ]; let simplified = simplify_polygon(&points, 0.1); assert_eq!(simplified.len(), 2); // Should only keep endpoints } } ================================================ FILE: src/coco/widget.rs ================================================ /// COCO dataset visualization widget and message handling /// /// This module is only compiled when the "coco" feature is enabled. /// It encapsulates all COCO-related messages and UI components. use std::path::PathBuf; use iced_winit::core::{Element, Color}; use iced_winit::core::Theme as WinitTheme; use iced_winit::runtime::Task; use iced_wgpu::Renderer; use iced_widget::{container, text}; use iced_core::padding; use iced_core::keyboard::{self, Key}; use iced_core::Vector; use log::{info, error, warn}; use crate::app::Message; use super::annotation_manager::AnnotationManager; use crate::pane::Pane; use crate::menu::PaneLayout; use super::parser::CocoDataset; /// Result type for COCO file loading: (dataset, path, skipped_count, warnings, invalid_image_ids) type CocoLoadResult = Result<(CocoDataset, PathBuf, usize, Vec, std::collections::HashSet), String>; /// COCO-specific messages grouped into a single enum variant #[derive(Debug, Clone)] pub enum CocoMessage { /// Load COCO JSON file from path LoadCocoFile(PathBuf), /// COCO file loaded (with result: dataset, path, skipped_count, warnings, images_with_invalid) CocoFileLoaded(CocoLoadResult), /// User selected image directory (with pending dataset, json path, and invalid images) ImageDirectorySelected(Option, CocoDataset, PathBuf, std::collections::HashSet), /// Toggle bounding box visibility for a pane ToggleBoundingBoxes(usize), // pane_index /// Toggle bounding boxes for all panes ToggleAllBoundingBoxes, /// Toggle segmentation masks for a pane ToggleSegmentationMasks(usize), // pane_index /// Toggle segmentation masks for all panes ToggleAllSegmentationMasks, /// Clear loaded annotations ClearAnnotations, /// Image zoom/pan changed (pane_index, scale, offset) ZoomChanged(usize, f32, Vector), } /// Convert CocoMessage to the main Message type impl From for Message { fn from(coco_msg: CocoMessage) -> Self { Message::CocoAction(coco_msg) } } /// Creates a badge widget showing COCO annotation status #[allow(dead_code)] pub fn coco_badge(has_annotations: bool, num_annotations: usize) -> Element<'static, Message, WinitTheme, Renderer> { if !has_annotations { return container(text("")) .width(0) .height(0) .into(); } container( text(format!("COCO ({})", num_annotations)) .size(12) .style(|_theme| iced_widget::text::Style { color: Some(Color::from([1.0, 1.0, 1.0])) }) ) .padding(padding::all(4)) .style(|_theme: &WinitTheme| container::Style { background: Some(Color::from([0.2, 0.5, 0.8]).into()), // Blue border: iced_winit::core::Border { radius: 4.0.into(), width: 0.0, color: Color::TRANSPARENT, }, ..container::Style::default() }) .into() } /// Empty badge for when COCO features are disabled pub fn empty_badge() -> Element<'static, Message, WinitTheme, Renderer> { container(text("")).width(0).height(0).into() } /// Handle COCO messages by delegating to the annotation manager /// /// This function encapsulates all COCO-related message handling logic, /// keeping it separate from the main app.rs update loop. pub fn handle_coco_message( coco_msg: CocoMessage, panes: &mut [Pane], annotation_manager: &mut AnnotationManager, ) -> Task { match coco_msg { CocoMessage::LoadCocoFile(path) => { info!("Loading COCO file: {}", path.display()); // Load the file asynchronously Task::perform( async move { // Parse the COCO file match CocoDataset::from_file(&path) { Ok(mut dataset) => { // Validate and clean the dataset (filter invalid annotations) let (skipped_count, warnings, images_with_invalid) = dataset.validate_and_clean(); Ok((dataset, path, skipped_count, warnings, images_with_invalid)) } Err(e) => Err(e), } }, |result| Message::CocoAction(CocoMessage::CocoFileLoaded(result)) ) } CocoMessage::CocoFileLoaded(result) => { match result { Ok((dataset, json_path, skipped_count, warnings, images_with_invalid)) => { info!("COCO dataset loaded: {} images, {} annotations", dataset.images.len(), dataset.annotations.len()); if skipped_count > 0 { warn!("Skipped {} invalid annotation(s)", skipped_count); for warning in &warnings { warn!("{}", warning); } } // Try to find image directory automatically let json_dir = json_path.parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); // Try common directory patterns let mut candidates = vec![ json_dir.join("images"), json_dir.join("img"), json_dir.join("val2017"), json_dir.join("train2017"), json_dir.clone(), ]; // If there's only a single directory in the JSON's parent directory, // add it as a candidate (handles arbitrary directory names) if let Ok(entries) = std::fs::read_dir(&json_dir) { let dirs: Vec = entries .flatten() .filter_map(|entry| { if entry.file_type().ok()?.is_dir() { Some(entry.path()) } else { None } }) .collect(); if dirs.len() == 1 { info!("Found single directory in JSON parent: {:?}", dirs[0]); let single_dir = &dirs[0]; candidates.insert(0, single_dir.clone()); // Also check nested single subdirectory (depth 1) // Handles structures like default/images or default/whatever_name if let Ok(nested_entries) = std::fs::read_dir(single_dir) { let nested_dirs: Vec = nested_entries .flatten() .filter_map(|entry| { if entry.file_type().ok()?.is_dir() { Some(entry.path()) } else { None } }) .collect(); if nested_dirs.len() == 1 { info!("Found single nested directory: {:?}", nested_dirs[0]); candidates.insert(0, nested_dirs[0].clone()); } } } } // Check if we can find images let test_filenames: Vec<_> = dataset.get_image_filenames() .into_iter() .take(3) .collect(); let mut found_dir: Option = None; for candidate in candidates { if candidate.exists() && candidate.is_dir() { let mut count = 0; for filename in &test_filenames { if candidate.join(filename).exists() { count += 1; } } if count >= 2 { found_dir = Some(candidate); break; } } } if let Some(dir) = found_dir { // Found directory, set it and open the directory for viewing if let Err(e) = annotation_manager.set_image_directory( dataset, json_path, dir.clone(), images_with_invalid, ) { error!("Failed to set image directory: {}", e); Task::none() } else { info!("COCO annotations loaded successfully from directory: {}", dir.display()); // Enable bbox and mask rendering by default for pane in panes.iter_mut() { pane.show_bboxes = true; pane.show_masks = true; } // Now open the image directory to actually load and display images // We use FolderOpened message to trigger the standard directory loading Task::done(Message::FolderOpened( Ok(dir.to_string_lossy().to_string()), 0 // pane_index )) } } else { // Need to prompt user for directory warn!("Could not auto-detect image directory, prompting user"); // Use native-dialog which has better Linux support let initial_dir = json_path.parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "~".to_string()); Task::perform( async move { let result = tokio::task::spawn_blocking(move || { native_dialog::FileDialog::new() .set_title("Select image directory for COCO dataset") .set_location(&initial_dir) .show_open_single_dir() }).await; let dir_path = match result { Ok(Ok(Some(path))) => Some(path), _ => None, }; (dir_path, dataset, json_path, images_with_invalid) }, |(dir_path, dataset, json_path, images_with_invalid)| { Message::CocoAction(CocoMessage::ImageDirectorySelected(dir_path, dataset, json_path, images_with_invalid)) } ) } } Err(e) => { error!("Failed to load COCO file: {}", e); Task::none() } } } CocoMessage::ImageDirectorySelected(maybe_path, dataset, json_path, images_with_invalid) => { if let Some(dir_path) = maybe_path { info!("User selected image directory: {}", dir_path.display()); // Set the image directory with the dataset if let Err(e) = annotation_manager.set_image_directory( dataset, json_path, dir_path.clone(), images_with_invalid, ) { error!("Failed to set image directory: {}", e); Task::none() } else { info!("COCO annotations loaded successfully from directory: {}", dir_path.display()); // Enable bbox and mask rendering by default for pane in panes.iter_mut() { pane.show_bboxes = true; pane.show_masks = true; } // Now open the image directory to actually load and display images Task::done(Message::FolderOpened( Ok(dir_path.to_string_lossy().to_string()), 0 // pane_index )) } } else { warn!("User cancelled directory selection"); Task::none() } } CocoMessage::ToggleBoundingBoxes(pane_index) => { if let Some(pane) = panes.get_mut(pane_index) { pane.show_bboxes = !pane.show_bboxes; info!("Toggled bounding boxes for pane {}: {}", pane_index, pane.show_bboxes); } Task::none() } CocoMessage::ToggleAllBoundingBoxes => { // Toggle all panes let new_state = panes.first() .map(|p| !p.show_bboxes) .unwrap_or(true); for pane in panes.iter_mut() { pane.show_bboxes = new_state; } info!("Toggled all bounding boxes: {}", new_state); Task::none() } CocoMessage::ToggleSegmentationMasks(pane_index) => { if let Some(pane) = panes.get_mut(pane_index) { pane.show_masks = !pane.show_masks; info!("Toggled segmentation masks for pane {}: {}", pane_index, pane.show_masks); } Task::none() } CocoMessage::ToggleAllSegmentationMasks => { // Toggle all panes let new_state = panes.first() .map(|p| !p.show_masks) .unwrap_or(true); for pane in panes.iter_mut() { pane.show_masks = new_state; } info!("Toggled all segmentation masks: {}", new_state); Task::none() } CocoMessage::ClearAnnotations => { annotation_manager.clear(); // Clear bbox and mask visibility on all panes for pane in panes.iter_mut() { pane.show_bboxes = false; pane.show_masks = false; } info!("Cleared COCO annotations"); Task::none() } CocoMessage::ZoomChanged(pane_index, scale, offset) => { // Update zoom state in the corresponding pane if let Some(pane) = panes.get_mut(pane_index) { pane.zoom_scale = scale; pane.zoom_offset = offset; log::debug!("ZoomChanged: pane={}, scale={:.2}, offset=({:.1}, {:.1})", pane_index, scale, offset.x, offset.y); } Task::none() } } } /// Handle COCO-related keyboard events /// /// Returns Some(Task) if the key was handled, None if not a COCO key pub fn handle_keyboard_event( key: &keyboard::Key, _modifiers: keyboard::Modifiers, pane_layout: &PaneLayout, last_opened_pane: isize, ) -> Option> { // Helper to determine current pane index let get_pane_index = || { if *pane_layout == PaneLayout::SinglePane { 0 } else { last_opened_pane as usize } }; match key.as_ref() { Key::Character("b") | Key::Character("B") => { // Toggle bounding boxes for current pane let pane_index = get_pane_index(); Some(Task::done(Message::CocoAction( CocoMessage::ToggleBoundingBoxes(pane_index) ))) } Key::Character("m") | Key::Character("M") => { // Toggle segmentation masks for current pane let pane_index = get_pane_index(); Some(Task::done(Message::CocoAction( CocoMessage::ToggleSegmentationMasks(pane_index) ))) } _ => None } } ================================================ FILE: src/config.rs ================================================ use once_cell::sync::Lazy; use crate::settings::{UserSettings, WindowState}; // Default values for configuration // These serve as fallback values and can be used for "reset to defaults" functionality pub const DEFAULT_CACHE_SIZE: usize = 5; pub const DEFAULT_MAX_LOADING_QUEUE_SIZE: usize = 3; pub const DEFAULT_MAX_BEING_LOADED_QUEUE_SIZE: usize = 3; pub const DEFAULT_WINDOW_WIDTH: u32 = 1200; pub const DEFAULT_WINDOW_HEIGHT: u32 = 800; pub const DEFAULT_ATLAS_SIZE: u32 = 2048; pub const DEFAULT_DOUBLE_CLICK_THRESHOLD_MS: u16 = 250; pub const DEFAULT_ARCHIVE_CACHE_SIZE: u64 = 200; // 200MB pub const DEFAULT_ARCHIVE_WARNING_THRESHOLD_MB: u64 = 500; // 500MB threshold for warning dialog pub struct Config { #[allow(dead_code)] pub cache_size: usize, // Cache window size pub max_loading_queue_size: usize, // Max size for the loading queue to prevent overloading pub max_being_loaded_queue_size: usize, pub window_width: u32, // Default window width pub window_height: u32, // Default window height pub atlas_size: u32, // Size of the square texture atlas used in iced_wgpu (affects slider performance) pub double_click_threshold_ms: u16, // Double-click detection threshold in milliseconds pub window_position_x: i32, pub window_position_y: i32, pub window_state: WindowState, } pub static CONFIG: Lazy = Lazy::new(|| { // Load settings from YAML file let settings = UserSettings::load(None); Config { cache_size: settings.cache_size, max_loading_queue_size: settings.max_loading_queue_size, max_being_loaded_queue_size: settings.max_being_loaded_queue_size, window_width: settings.window_width, window_height: settings.window_height, atlas_size: settings.atlas_size, double_click_threshold_ms: settings.double_click_threshold_ms, window_position_x: settings.window_position_x, window_position_y: settings.window_position_y, window_state: settings.window_state, } }); ================================================ FILE: src/exif_utils.rs ================================================ //! EXIF orientation utilities for automatic image rotation correction. //! //! This module provides EXIF-aware image decoding that automatically applies //! orientation corrections based on EXIF metadata embedded in images (primarily JPEG). use image::{DynamicImage, ImageDecoder, ImageReader}; use std::io::Cursor; #[allow(unused_imports)] use log::{debug, warn, error}; /// Decodes image from bytes with EXIF orientation applied. /// /// Uses image crate v0.25+ built-in orientation support: /// 1. Creates decoder from bytes /// 2. Reads EXIF orientation (if present) /// 3. Decodes to DynamicImage /// 4. Applies orientation transformation /// /// Falls back to simple decode if decoder creation fails (some formats /// may not support the decoder interface). pub fn decode_with_exif_orientation(bytes: &[u8]) -> Result { let cursor = Cursor::new(bytes); let reader = ImageReader::new(cursor) .with_guessed_format() .map_err(|e| { error!("Failed to guess image format: {}", e); std::io::ErrorKind::InvalidData })?; // Try to get orientation from decoder match reader.into_decoder() { Ok(mut decoder) => { // Get orientation (defaults to NoTransforms if not present or unsupported format) let orientation = decoder.orientation() .unwrap_or(image::metadata::Orientation::NoTransforms); if orientation != image::metadata::Orientation::NoTransforms { debug!("EXIF orientation detected: {:?}", orientation); } // Decode the image let mut img = DynamicImage::from_decoder(decoder) .map_err(|e| { error!("Failed to decode image: {}", e); std::io::ErrorKind::InvalidData })?; // Apply orientation if not NoTransforms if orientation != image::metadata::Orientation::NoTransforms { debug!("Applying EXIF orientation transformation"); img.apply_orientation(orientation); } Ok(img) } Err(e) => { // Fallback: some formats may not support decoder interface // Fall back to simple decode without orientation warn!("Decoder creation failed, falling back to simple decode: {}", e); let cursor = Cursor::new(bytes); ImageReader::new(cursor) .with_guessed_format() .map_err(|_| std::io::ErrorKind::InvalidData)? .decode() .map_err(|e| { error!("Failed to decode image: {}", e); std::io::ErrorKind::InvalidData }) } } } /// Get orientation-aware dimensions from image bytes. /// /// For 90/270 degree rotations (and their flip variants), the width and height /// are swapped to reflect the final displayed dimensions after EXIF orientation is applied. pub fn get_orientation_aware_dimensions(bytes: &[u8]) -> (u32, u32) { use image::metadata::Orientation; let cursor = Cursor::new(bytes); if let Ok(reader) = ImageReader::new(cursor).with_guessed_format() { if let Ok(mut decoder) = reader.into_decoder() { let orientation = decoder.orientation() .unwrap_or(Orientation::NoTransforms); let (w, h) = decoder.dimensions(); // Swap dimensions for orientations that include 90/270 degree rotations return match orientation { Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH // EXIF 5: 90 CCW + flip = swaps dimensions | Orientation::Rotate270FlipH // EXIF 7: 270 CCW + flip = swaps dimensions => (h, w), _ => (w, h), }; } } // Fallback: try header-only read without orientation let cursor = Cursor::new(bytes); ImageReader::new(cursor) .with_guessed_format() .ok() .and_then(|r| r.into_dimensions().ok()) .unwrap_or((0, 0)) } ================================================ FILE: src/file_io.rs ================================================ use std::fs; use std::path::Path; use std::path::PathBuf; use tokio::io::AsyncReadExt; use futures::future::join_all; use crate::cache::img_cache::LoadOperation; use tokio::time::Instant; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; use std::error::Error as StdError; use std::io; use std::io::Cursor; use std::sync::{Arc, Mutex}; use once_cell::sync::Lazy; use image::{GenericImageView, ImageReader}; use iced_wgpu::wgpu; use crate::cache::img_cache::CachedData; use crate::utils::timing::TimingStats; use crate::cache::img_cache::CacheStrategy; use iced_wgpu::engine::CompressionStrategy; use image::DynamicImage; const ALLOWED_EXTENSIONS: [&str; 15] = ["jpg", "jpeg", "png", "gif", "bmp", "ico", "tiff", "tif", "webp", "pnm", "pbm", "pgm", "ppm", "qoi", "tga"]; /// Check if the given bytes represent a JPEG 2000 file by checking magic bytes #[cfg(feature = "jp2")] fn is_jp2_format(bytes: &[u8]) -> bool { // JP2 file format: starts with 0x0000000C 6A502020 0D0A870A // or JPEG 2000 codestream: starts with 0xFF4FFF51 if bytes.len() < 12 { return false; } // JP2 container format magic let jp2_magic = [0x00, 0x00, 0x00, 0x0C, 0x6A, 0x50, 0x20, 0x20, 0x0D, 0x0A, 0x87, 0x0A]; if bytes.starts_with(&jp2_magic) { return true; } // Raw JPEG 2000 codestream (j2k/j2c) if bytes.len() >= 4 && bytes[0] == 0xFF && bytes[1] == 0x4F && bytes[2] == 0xFF && bytes[3] == 0x51 { return true; } false } /// Decode JPEG 2000 image from bytes #[cfg(feature = "jp2")] fn decode_jp2(bytes: &[u8]) -> Result { use jpeg2k::Image as Jp2Image; let jp2_image = Jp2Image::from_bytes(bytes) .map_err(|e| { error!("Failed to decode JPEG 2000 image: {}", e); std::io::ErrorKind::InvalidData })?; // TryFrom is implemented for &Image, not Image DynamicImage::try_from(&jp2_image) .map_err(|e: jpeg2k::error::Error| { error!("Failed to convert JPEG 2000 to DynamicImage: {}", e); std::io::ErrorKind::InvalidData }) } /// Decode image from bytes, handling both standard formats and JPEG 2000. /// Applies EXIF orientation correction for supported formats (primarily JPEG). pub fn decode_image_from_bytes(bytes: &[u8]) -> Result { // Check for JPEG 2000 format first when feature is enabled // Note: JP2 doesn't use EXIF orientation, so decode directly #[cfg(feature = "jp2")] if is_jp2_format(bytes) { return decode_jp2(bytes); } // Use EXIF-aware decoding for standard formats crate::exif_utils::decode_with_exif_orientation(bytes) } /// Check if a file extension is a supported image format fn is_supported_extension(ext: &str) -> bool { let ext_lower = ext.to_lowercase(); if ALLOWED_EXTENSIONS.contains(&ext_lower.as_str()) { return true; } #[cfg(feature = "jp2")] if ALLOWED_EXTENSIONS_JP2.contains(&ext_lower.as_str()) { return true; } false } #[cfg(feature = "jp2")] const ALLOWED_EXTENSIONS_JP2: [&str; 3] = ["jp2", "j2k", "j2c"]; pub const ALLOWED_COMPRESSED_FILES: [&str; 3] = ["zip", "rar", "7z"]; pub fn supported_image(name: &str) -> bool { // Filter out macOS metadata files if name.starts_with("__MACOSX/") { return false; } let ext = name.split('.').next_back().unwrap_or("").to_lowercase(); if ALLOWED_EXTENSIONS.contains(&ext.as_str()) { return true; } #[cfg(feature = "jp2")] if ALLOWED_EXTENSIONS_JP2.contains(&ext.as_str()) { return true; } false } static IMAGE_LOAD_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("Image Load")) }); static GPU_UPLOAD_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("GPU Upload")) }); #[allow(dead_code)] #[derive(Debug, Clone)] pub enum Error { DialogClosed, InvalidSelection, InvalidExtension, } pub fn get_filename(path: &str) -> Option { std::path::Path::new(path) .file_name() .and_then(|os_str| os_str.to_str()) .map(|s| s.to_string()) } /// Reads an image file into a byte vector with dispatch based on PathSource. /// /// This function uses type-safe routing for optimal performance: /// - Filesystem: Direct filesystem I/O with mmap optimization /// - Preloaded: Direct HashMap lookup in ArchiveCache /// - Archive: Direct archive reading without unnecessary checks /// /// # Arguments /// * `path_source` - The typed path indicating source and loading strategy /// * `archive_cache` - The archive cache for archive/preloaded content /// /// # Returns /// * `Ok(Vec)` - The raw bytes of the image file /// * `Err(io::Error)` - An error if reading fails pub fn read_image_bytes(path_source: &crate::cache::img_cache::PathSource, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> Result, std::io::Error> { use std::fs::File; use std::io::{self, Read}; use memmap2::Mmap; use crate::cache::img_cache::PathSource; // Dispatch based on PathSource type match path_source { PathSource::Filesystem(path) => { // Direct filesystem reading with mmap optimization if !path.exists() { return Err(io::Error::new( io::ErrorKind::NotFound, format!("Filesystem file not found: {}", path.display()) )); } let file = File::open(path)?; let metadata = file.metadata()?; let file_size = metadata.len() as usize; // Use mmap for files over 1MB, regular reading for smaller files if file_size > 1_048_576 { let mmap = unsafe { Mmap::map(&file)? }; let bytes = mmap.to_vec(); debug!("Read {} bytes from filesystem using mmap: {}", bytes.len(), path.display()); Ok(bytes) } else { // For smaller files, regular reading is often faster let mut buffer = Vec::with_capacity(file_size); let mut file = File::open(path)?; file.read_to_end(&mut buffer)?; debug!("Read {} bytes from filesystem: {}", buffer.len(), path.display()); Ok(buffer) } }, PathSource::Preloaded(path) => { // Direct HashMap lookup - fastest path for preloaded content let cache = archive_cache.ok_or_else(|| io::Error::new( io::ErrorKind::InvalidInput, "Archive cache required for preloaded content" ))?; let path_str = path.to_string_lossy(); if let Some(data) = cache.get_preloaded_data(&path_str) { debug!("Using preloaded data for: {}", path_str); Ok(data.to_vec()) } else { Err(io::Error::new( io::ErrorKind::NotFound, format!("Preloaded data not found: {}", path_str) )) } }, PathSource::Archive(path) => { // Direct archive reading - no filesystem checks let cache = archive_cache.ok_or_else(|| io::Error::new( io::ErrorKind::InvalidInput, "Archive cache required for archive content" ))?; let path_str = path.to_string_lossy(); debug!("Reading from archive: {}", path_str); cache.read_from_archive(&path_str) .map_err(|e| io::Error::other(format!("Failed to read from archive: {}", e))) } } } /// Reads image bytes and returns (bytes, file_size_in_bytes) pub fn read_image_bytes_with_size(path_source: &crate::cache::img_cache::PathSource, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> Result<(Vec, u64), std::io::Error> { use std::fs::File; use std::io::{self, Read}; use memmap2::Mmap; use crate::cache::img_cache::PathSource; // Dispatch based on PathSource type match path_source { PathSource::Filesystem(path) => { // Direct filesystem reading with mmap optimization if !path.exists() { return Err(io::Error::new( io::ErrorKind::NotFound, format!("Filesystem file not found: {}", path.display()) )); } let file = File::open(path)?; let metadata = file.metadata()?; let file_size = metadata.len(); // Use mmap for files over 1MB, regular reading for smaller files if file_size > 1_048_576 { let mmap = unsafe { Mmap::map(&file)? }; let bytes = mmap.to_vec(); debug!("Read {} bytes from filesystem using mmap: {}", bytes.len(), path.display()); Ok((bytes, file_size)) } else { // For smaller files, regular reading is often faster let mut buffer = Vec::with_capacity(file_size as usize); let mut file = File::open(path)?; file.read_to_end(&mut buffer)?; debug!("Read {} bytes from filesystem: {}", buffer.len(), path.display()); Ok((buffer, file_size)) } }, PathSource::Preloaded(path) => { // Direct HashMap lookup - fastest path for preloaded content let cache = archive_cache.ok_or_else(|| io::Error::new( io::ErrorKind::InvalidInput, "Archive cache required for preloaded content" ))?; let path_str = path.to_string_lossy(); if let Some(data) = cache.get_preloaded_data(&path_str) { debug!("Using preloaded data for: {}", path_str); let file_size = data.len() as u64; Ok((data.to_vec(), file_size)) } else { Err(io::Error::new( io::ErrorKind::NotFound, format!("Preloaded data not found: {}", path_str) )) } }, PathSource::Archive(path) => { // Direct archive reading - no filesystem checks let cache = archive_cache.ok_or_else(|| io::Error::new( io::ErrorKind::InvalidInput, "Archive cache required for archive content" ))?; let path_str = path.to_string_lossy(); debug!("Reading from archive: {}", path_str); let bytes = cache.read_from_archive(&path_str) .map_err(|e| io::Error::other(format!("Failed to read from archive: {}", e)))?; let file_size = bytes.len() as u64; Ok((bytes, file_size)) } } } /// Gets file size efficiently without reading the entire file content. /// For filesystem files, uses std::fs::metadata() which only reads the inode. /// For archive/preloaded content, reads from archive cache. pub fn get_file_size(path_source: &crate::cache::img_cache::PathSource, archive_cache: Option<&mut crate::archive_cache::ArchiveCache>) -> u64 { use crate::cache::img_cache::PathSource; match path_source { PathSource::Filesystem(path) => { // Use fs::metadata() - only reads inode, not file content (O(1) operation) std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) }, PathSource::Preloaded(path) => { // Need to check preloaded data length if let Some(cache) = archive_cache { let path_str = path.to_string_lossy(); cache.get_preloaded_data(&path_str) .map(|data| data.len() as u64) .unwrap_or(0) } else { 0 } }, PathSource::Archive(path) => { // For archives, we need to read from cache to get size if let Some(cache) = archive_cache { let path_str = path.to_string_lossy(); cache.read_from_archive(&path_str) .map(|bytes| bytes.len() as u64) .unwrap_or(0) } else { 0 } } } } #[allow(dead_code)] pub async fn async_load_image(path: impl AsRef, operation: LoadOperation) -> Result<(Option>, Option), std::io::ErrorKind> { let file_path = path.as_ref(); match tokio::fs::File::open(file_path).await { Ok(mut file) => { let mut buffer = Vec::new(); if file.read_to_end(&mut buffer).await.is_ok() { Ok((Some(buffer), Some(operation) )) } else { Err(std::io::ErrorKind::InvalidData) } } Err(e) => Err(e.kind()), } } #[allow(dead_code)] async fn load_image_cpu_async(path_source: Option, archive_cache: Option>>) -> Result, std::io::ErrorKind> { use crate::cache::img_cache::ImageMetadata; // Load a single image asynchronously if let Some(path_source) = path_source { let start = Instant::now(); debug!("load_image_cpu_async - Starting to load: {:?}", path_source.file_name()); // Dispatch based on PathSource type - get bytes and file size let (bytes, file_size) = match &path_source { crate::cache::img_cache::PathSource::Filesystem(path) => { // Direct filesystem reading - get file size from metadata let metadata = match tokio::fs::metadata(path).await { Ok(m) => m, Err(e) => return Err(e.kind()), }; let file_size = metadata.len(); match tokio::fs::read(path).await { Ok(bytes) => (bytes, file_size), Err(e) => return Err(e.kind()), } }, crate::cache::img_cache::PathSource::Archive(_) | crate::cache::img_cache::PathSource::Preloaded(_) => { // Archive content requires archive cache if let Some(cache_arc) = archive_cache { let cache_bytes_result = { match cache_arc.lock() { Ok(mut cache) => read_image_bytes_with_size(&path_source, Some(&mut *cache)), Err(_) => Err(std::io::Error::other("Archive cache lock failed")), } }; match cache_bytes_result { Ok((bytes, file_size)) => (bytes, file_size), Err(e) => { error!("Failed to read archive content: {}", e); return Err(std::io::ErrorKind::Other); } } } else { error!("Archive cache required for archive/preloaded content"); return Err(std::io::ErrorKind::InvalidInput); } } }; // Get image dimensions efficiently using header-only read let (width, height) = ImageReader::new(Cursor::new(&bytes)) .with_guessed_format() .ok() .and_then(|r| r.into_dimensions().ok()) .unwrap_or((0, 0)); let metadata = ImageMetadata::new(width, height, file_size); let total_time = start.elapsed(); debug!("load_image_cpu_async - Total load time: {:?}", total_time); Ok(Some((CachedData::Cpu(bytes), metadata))) } else { Ok(None) } } #[allow(dead_code)] async fn load_image_gpu_async( path_source: Option, device: &Arc, queue: &Arc, compression_strategy: CompressionStrategy, archive_cache: Option>> ) -> Result, std::io::ErrorKind> { use crate::cache::img_cache::ImageMetadata; if let Some(path_source) = path_source { let start = Instant::now(); // Dispatch based on PathSource type - get decoded image and file size let (img_result, file_size) = match &path_source { crate::cache::img_cache::PathSource::Filesystem(path) => { // Read bytes and use unified decode function for format detection // Get file size first let file_size = match std::fs::metadata(path) { Ok(m) => m.len(), Err(e) => { error!("Failed to read filesystem metadata: {}", e); return Err(e.kind()); } }; match std::fs::read(path) { Ok(bytes) => (decode_image_from_bytes(&bytes), file_size), Err(e) => { error!("Failed to read filesystem image: {}", e); return Err(e.kind()); } } }, crate::cache::img_cache::PathSource::Archive(_) | crate::cache::img_cache::PathSource::Preloaded(_) => { // Archive content requires archive cache if let Some(cache_arc) = &archive_cache { let cache_bytes_result = { match cache_arc.lock() { Ok(mut cache) => read_image_bytes_with_size(&path_source, Some(&mut *cache)), Err(e) => { error!("Failed to lock archive cache: {}", e); Err(std::io::Error::other("Archive cache lock failed")) } } }; match cache_bytes_result { Ok((bytes, file_size)) => (decode_image_from_bytes(&bytes), file_size), Err(e) => { error!("Failed to read archive content: {}", e); return Err(std::io::ErrorKind::Other); } } } else { error!("Archive cache required for archive/preloaded content"); return Err(std::io::ErrorKind::InvalidInput); } } }; match img_result { Ok(img) => { // Apply size check and resize if image exceeds 8192px limit let img = crate::cache::cache_utils::check_and_resize_if_oversized(img); let (width, height) = img.dimensions(); let rgba = img.to_rgba8(); let rgba_data = rgba.as_raw(); // Create metadata with original file size and current dimensions let metadata = ImageMetadata::new(width, height, file_size); let duration = start.elapsed(); IMAGE_LOAD_STATS.lock().unwrap().add_measurement(duration); let upload_start = Instant::now(); // Use our utility to check if compression is applicable let use_compression = crate::cache::cache_utils::should_use_compression( width, height, compression_strategy ); // Create texture with the appropriate format let texture = crate::cache::cache_utils::create_gpu_texture( device, width, height, compression_strategy ); if use_compression { // Use utility to compress and upload let (compressed_data, row_bytes) = crate::cache::cache_utils::compress_image_data( rgba_data, width, height ); // Upload using the utility crate::cache::cache_utils::upload_compressed_texture( queue, &texture, &compressed_data, width, height, row_bytes ); let upload_duration = upload_start.elapsed(); GPU_UPLOAD_STATS.lock().unwrap().add_measurement(upload_duration); return Ok(Some((CachedData::BC1(Arc::new(texture)), metadata))); } else { // Upload uncompressed crate::cache::cache_utils::upload_uncompressed_texture( queue, &texture, rgba_data, width, height ); let upload_duration = upload_start.elapsed(); GPU_UPLOAD_STATS.lock().unwrap().add_measurement(upload_duration); return Ok(Some((CachedData::Gpu(Arc::new(texture)), metadata))); } } Err(e) => { error!("Error opening image: {:?}", e); return Err(std::io::ErrorKind::InvalidData); } } } Ok(None) } pub async fn load_images_async( paths: Vec>, cache_strategy: CacheStrategy, device: &Arc, queue: &Arc, compression_strategy: CompressionStrategy, load_operation: LoadOperation, archive_caches: Vec>>> ) -> Result<(Vec>, Vec>, Option), std::io::ErrorKind> { let start = Instant::now(); debug!("load_images_async - cache_strategy: {:?}, compression: {:?}", cache_strategy, compression_strategy); let futures = paths.into_iter().enumerate().map(|(i, path)| { let device = Arc::clone(device); let queue = Arc::clone(queue); let pane_archive_cache = archive_caches.get(i).cloned().flatten(); async move { match cache_strategy { CacheStrategy::Cpu => { debug!("load_images_async - loading image with CPU strategy"); load_image_cpu_async(path, pane_archive_cache).await }, CacheStrategy::Gpu => { debug!("load_images_async - loading image with GPU strategy and compression: {:?}", compression_strategy); load_image_gpu_async(path, &device, &queue, compression_strategy, pane_archive_cache).await }, } } }); let results = join_all(futures).await; let duration = start.elapsed(); debug!("Finished loading images in {:?}", duration); // Separate images and metadata from the results let mut images = Vec::new(); let mut metadata_vec = Vec::new(); for result in results { match result.ok().flatten() { Some((data, metadata)) => { images.push(Some(data)); metadata_vec.push(Some(metadata)); } None => { images.push(None); metadata_vec.push(None); } } } Ok((images, metadata_vec, Some(load_operation))) } pub async fn pick_folder() -> Result { let handle= rfd::AsyncFileDialog::new() .set_title("Open Folder with images") .pick_folder() .await; match handle { Some(selected_folder) => { // Convert the PathBuf to a String let selected_folder_string = selected_folder .path() .to_string_lossy() .to_string(); Ok(selected_folder_string) } None => Err(Error::DialogClosed), } } pub async fn pick_save_file() -> Result { #[cfg(feature = "jp2")] let extensions = [&ALLOWED_EXTENSIONS[..], &ALLOWED_EXTENSIONS_JP2[..]].concat(); #[cfg(not(feature = "jp2"))] let extensions = [&ALLOWED_EXTENSIONS[..]].concat(); let handle = rfd::FileDialog::new() .set_title("Save File") .add_filter("Supported files", extensions.as_slice()) .save_file(); match handle { Some(file_info) => { if let Some(extension) = file_info.extension().and_then(|ext| ext.to_str()) { if extensions.contains(&extension.to_lowercase().as_str()) { Ok(file_info) } else { Err(Error::InvalidExtension) } } else { Err(Error::InvalidExtension) } } None => Err(Error::DialogClosed), } } pub async fn pick_file() -> Result { // https://stackoverflow.com/a/71194526 #[cfg(feature = "jp2")] let extensions = [&ALLOWED_COMPRESSED_FILES[..], &ALLOWED_EXTENSIONS[..], &ALLOWED_EXTENSIONS_JP2[..]].concat(); #[cfg(not(feature = "jp2"))] let extensions = [&ALLOWED_COMPRESSED_FILES[..], &ALLOWED_EXTENSIONS[..]].concat(); let handle = rfd::FileDialog::new() .set_title("Open File") .add_filter("Supported Files", extensions.as_slice()) .pick_file(); match handle { Some(file_info) => { let path = file_info.as_path(); // Convert the extension to lowercase for case-insensitive comparison if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) { if extensions.contains(&extension.to_lowercase().as_str()) { Ok(path.to_string_lossy().to_string()) } else { Err(Error::InvalidExtension) } } else { Err(Error::InvalidExtension) } } None => Err(Error::DialogClosed), } } /// Show memory warning dialog for large solid 7z archives /// Returns true if user wants to proceed, false if cancelled pub fn show_memory_warning_sync(archive_size_mb: u64, available_gb: f64, is_recommended: bool) -> bool { let warning_level = if is_recommended { "Notice" } else { "Warning" }; let memory_info = if available_gb > 0.0 { format!("Available memory: {:.1} GB\n\n", available_gb) } else { // Don't show memory size when it's 0.0 GB // related: https://github.com/GuillaumeGomez/sysinfo/issues/1030 String::new() }; let memory_note = if available_gb == 0.0 { "Memory information unavailable on this system." } else if is_recommended { "Sufficient memory available, but archive is large." } else { "Low available memory - may cause system slowdown." }; let message = format!( "{}: Large Archive Detected\n\n\ Archive size: {:.1} MB\n\ {}{}\n\n\ The application will load the archive into memory for optimal performance. \ This may take a moment and use significant RAM.\n\n\ Continue?", warning_level, archive_size_mb, memory_info, memory_note ); let dialog_result = rfd::MessageDialog::new() .set_title("ViewSkater") .set_description(&message) .set_buttons(rfd::MessageButtons::YesNo) .set_level(if is_recommended { rfd::MessageLevel::Info } else { rfd::MessageLevel::Warning }) .show(); matches!(dialog_result, rfd::MessageDialogResult::Yes) } #[allow(dead_code)] pub async fn empty_async_block(operation: LoadOperation) -> Result<(Option, Option), std::io::ErrorKind> { Ok((None, Some(operation))) } pub async fn empty_async_block_vec(operation: LoadOperation, count: usize) -> Result<(Vec>, Vec>, Option), std::io::ErrorKind> { Ok((vec![None; count], vec![None; count], Some(operation))) } pub async fn _literal_empty_async_block() -> Result<(), std::io::ErrorKind> { Ok(()) } pub fn is_file(path: &Path) -> bool { fs::metadata(path).map(|metadata| metadata.is_file()).unwrap_or(false) } pub fn is_directory(path: &Path) -> bool { fs::metadata(path).map(|metadata| metadata.is_dir()).unwrap_or(false) } pub fn get_file_index(files: &[PathBuf], file: &Path) -> Option { let file_name = file.file_name()?; files.iter().position(|f| f.file_name() == Some(file_name)) } #[derive(Debug)] pub enum ImageError { NoImagesFound, DirectoryError(io::Error), // Add other error types as needed } impl std::fmt::Display for ImageError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ImageError::NoImagesFound => write!(f, "No supported images found in directory"), ImageError::DirectoryError(e) => write!(f, "Directory error: {}", e), } } } impl StdError for ImageError {} /// Helper function to handle fallback when directory reading fails /// This tries to treat the directory path as a single image file (useful for sandboxed apps) #[cfg(target_os = "macos")] fn handle_fallback_for_single_file( directory_path: &Path, original_error: std::io::Error ) -> Result, ImageError> { crate::logging::write_crash_debug_log("handle_fallback_for_single_file ENTRY"); crate::logging::write_crash_debug_log(&format!("Directory path: {}", directory_path.display())); crate::logging::write_crash_debug_log(&format!("Original error: {}", original_error)); debug!("🔄 FALLBACK: Attempting single file fallback due to directory access failure"); debug!("Directory path: {}", directory_path.display()); debug!("Original error: {}", original_error); // If we can't read the directory, check if the path itself is a valid image file if directory_path.is_file() { crate::logging::write_crash_debug_log("Path is a file, checking if it's a valid image"); debug!("✅ Path is a file, checking if it's a valid image"); if let Some(extension) = directory_path.extension().and_then(std::ffi::OsStr::to_str) { crate::logging::write_crash_debug_log(&format!("File extension: {}", extension)); debug!("File extension: {}", extension); if is_supported_extension(extension) { crate::logging::write_crash_debug_log(&format!("✅ Valid image file found: {}", directory_path.display())); debug!("✅ Valid image file found: {}", directory_path.display()); // Return just this single file return Ok(vec![directory_path.to_path_buf()]); } else { crate::logging::write_crash_debug_log(&format!("❌ File has unsupported extension: {}", extension)); debug!("❌ File has unsupported extension: {}", extension); } } else { crate::logging::write_crash_debug_log("❌ File has no extension"); debug!("❌ File has no extension"); } } else { crate::logging::write_crash_debug_log("❌ Path is not a file"); debug!("❌ Path is not a file"); // Additional debugging for macOS sandboxing #[cfg(target_os = "macos")] { crate::logging::write_crash_debug_log("🔍 macOS-specific debugging:"); crate::logging::write_crash_debug_log(" - This may be due to App Store sandboxing restrictions"); crate::logging::write_crash_debug_log(" - The app may have individual file access but not directory access"); debug!("🔍 macOS-specific debugging:"); debug!(" - This may be due to App Store sandboxing restrictions"); debug!(" - The app may have individual file access but not directory access"); let path_str = directory_path.to_string_lossy(); if crate::macos_file_access::macos_file_handler::has_security_scoped_access(&path_str) { crate::logging::write_crash_debug_log(" - Has security-scoped access for this path"); debug!(" - Has security-scoped access for this path"); } else { crate::logging::write_crash_debug_log(" - No security-scoped access for this path"); debug!(" - No security-scoped access for this path"); } if crate::macos_file_access::macos_file_handler::has_full_disk_access() { crate::logging::write_crash_debug_log(" - Has full disk access"); debug!(" - Has full disk access"); } else { crate::logging::write_crash_debug_log(" - No full disk access"); debug!(" - No full disk access"); } } } crate::logging::write_crash_debug_log("❌ FALLBACK FAILED: Cannot process as single file, returning original error"); debug!("❌ FALLBACK FAILED: Cannot process as single file, returning original error"); // If it's not a valid image file, return the original error Err(ImageError::DirectoryError(original_error)) } /// Helper function to request directory access when bookmark restoration fails /// This handles the permission dialog flow and fallbacks #[cfg(target_os = "macos")] fn request_directory_access_and_retry( directory_path: &Path, original_error: std::io::Error ) -> Result, ImageError> { crate::logging::write_crash_debug_log("request_directory_access_and_retry ENTRY"); crate::logging::write_crash_debug_log(&format!("Directory path: {}", directory_path.display())); crate::logging::write_crash_debug_log(&format!("Original error: {}", original_error)); #[cfg(target_os = "macos")] { crate::logging::write_crash_debug_log("macOS path - attempting to request new directory access"); debug!("Attempting to request new directory access"); // STEP 0: Try to restore directory access from stored bookmarks before prompting let path_str = directory_path.to_string_lossy(); crate::logging::write_crash_debug_log("STEP 0 (retry): Attempting bookmark restoration before prompting user"); if crate::macos_file_access::macos_file_handler::restore_directory_access_for_path(&path_str) { crate::logging::write_crash_debug_log("STEP 0 (retry): ✅ Restored directory access from bookmark, retrying read"); // Use the same NSURL-based approach as the main function for consistency crate::logging::write_crash_debug_log("STEP 0 (retry): Attempting to read directory using resolved NSURL directly"); if let Some(file_paths) = crate::macos_file_access::macos_file_handler::read_directory_with_security_scoped_url(&path_str) { crate::logging::write_crash_debug_log(&format!("STEP 0 (retry): ✅ Successfully read directory using NSURL, found {} files", file_paths.len())); // Convert to DirEntry-like structure for compatibility with existing code let mut image_paths = Vec::new(); for file_path in file_paths { let path = std::path::Path::new(&file_path); if let Some(extension) = path.extension() { if let Some(ext_str) = extension.to_str() { if is_supported_extension(ext_str) { image_paths.push(path.to_path_buf()); } } } } crate::logging::write_crash_debug_log(&format!("STEP 0 (retry): ✅ Found {} image files", image_paths.len())); return Ok(image_paths); } else { crate::logging::write_crash_debug_log("STEP 0 (retry): ❌ Failed to read directory using resolved NSURL"); } } else { crate::logging::write_crash_debug_log("STEP 0 (retry): ❌ No stored bookmark or restoration failed"); } // Try permission dialog first crate::logging::write_crash_debug_log("Getting accessible paths"); let accessible_paths = crate::macos_file_access::macos_file_handler::get_accessible_paths(); crate::logging::write_crash_debug_log(&format!("Got {} accessible paths", accessible_paths.len())); if let Some(file_path) = accessible_paths.first() { crate::logging::write_crash_debug_log(&format!("Using first accessible path: {}", file_path)); crate::logging::write_crash_debug_log("About to call request_parent_directory_permission_dialog"); if crate::macos_file_access::macos_file_handler::request_parent_directory_permission_dialog(file_path) { crate::logging::write_crash_debug_log("Permission dialog succeeded, retrying directory read"); debug!("Permission dialog succeeded, retrying directory read"); // CRITICAL FIX: Use the resolved NSURL directly for file operations, don't convert to path string let path_str = directory_path.to_string_lossy(); crate::logging::write_crash_debug_log("Attempting to read directory using resolved NSURL directly after permission dialog"); if let Some(file_paths) = crate::macos_file_access::macos_file_handler::read_directory_with_security_scoped_url(&path_str) { crate::logging::write_crash_debug_log(&format!("✅ Successfully read directory using NSURL after permission dialog, found {} files", file_paths.len())); // Convert to DirEntry-like structure for compatibility with existing code let mut image_paths = Vec::new(); for file_path in file_paths { let path = std::path::Path::new(&file_path); if let Some(extension) = path.extension() { if let Some(ext_str) = extension.to_str() { if is_supported_extension(ext_str) { image_paths.push(path.to_path_buf()); } } } } crate::logging::write_crash_debug_log(&format!("✅ Found {} image files after permission dialog", image_paths.len())); return Ok(image_paths); } else { crate::logging::write_crash_debug_log("❌ Failed to read directory using resolved NSURL after permission dialog"); } } else { crate::logging::write_crash_debug_log("User declined permission dialog"); debug!("User declined permission dialog"); } } else { crate::logging::write_crash_debug_log("No accessible paths found"); } // Fallback to single file handling if all else fails crate::logging::write_crash_debug_log("All directory access methods failed, falling back to single file handling"); debug!("All directory access methods failed, falling back to single file handling"); return handle_fallback_for_single_file(directory_path, original_error); } #[cfg(not(target_os = "macos"))] { crate::logging::write_crash_debug_log("Non-macOS platform - returning original error"); return Err(ImageError::DirectoryError(original_error)); } } /// Helper function to process directory entries and filter for image files fn process_directory_entries( entries: std::fs::ReadDir, directory_path: &Path ) -> Result, ImageError> { let mut image_paths: Vec = Vec::new(); for entry in entries.flatten() { if let Some(extension) = entry.path().extension().and_then(std::ffi::OsStr::to_str) { if is_supported_extension(extension) { image_paths.push(entry.path()); } } } if image_paths.is_empty() { debug!("No image files found in directory: {}", directory_path.display()); Err(ImageError::NoImagesFound) } else { debug!("Found {} image files", image_paths.len()); // Sort paths like Nautilus file viewer for consistent ordering alphanumeric_sort::sort_path_slice(&mut image_paths); Ok(image_paths) } } /// Cross-platform image path discovery /// Routes to OS-specific implementations based on compile target pub fn get_image_paths(directory_path: &Path) -> Result, ImageError> { #[cfg(target_os = "macos")] { get_image_paths_macos(directory_path) } #[cfg(not(target_os = "macos"))] { get_image_paths_standard(directory_path) } } /// Standard implementation for non-macOS platforms /// Simple directory reading without sandbox considerations #[cfg(not(target_os = "macos"))] fn get_image_paths_standard(directory_path: &Path) -> Result, ImageError> { debug!("Standard directory reading for path: {}", directory_path.display()); let dir_entries = fs::read_dir(directory_path) .map_err(ImageError::DirectoryError)?; process_directory_entries(dir_entries, directory_path) } /// macOS implementation with App Store sandbox support /// Handles security-scoped bookmarks and "Open With" scenarios #[cfg(target_os = "macos")] fn get_image_paths_macos(directory_path: &Path) -> Result, ImageError> { crate::logging::write_crash_debug_log("======== get_image_paths_macos ENTRY ========"); crate::logging::write_crash_debug_log(&format!("Directory path: {}", directory_path.display())); // Try standard directory reading first match fs::read_dir(directory_path) { Ok(entries) => { crate::logging::write_crash_debug_log("✅ Standard directory read successful"); debug!("Successfully read directory normally (drag-and-drop or non-sandboxed): {}", directory_path.display()); return process_directory_entries(entries, directory_path); } Err(e) => { crate::logging::write_crash_debug_log(&format!("❌ Standard directory read failed: {}", e)); debug!("Failed to read directory normally: {} (error: {})", directory_path.display(), e); // Handle macOS App Store sandbox scenarios return handle_macos_sandbox_access(directory_path, e); } } } /// Handle macOS App Store sandbox directory access /// This includes bookmark restoration and "Open With" permission dialogs #[cfg(target_os = "macos")] fn handle_macos_sandbox_access( directory_path: &Path, original_error: std::io::Error ) -> Result, ImageError> { let path_str = directory_path.to_string_lossy(); crate::logging::write_crash_debug_log("macOS sandbox - checking for 'Open With' scenario"); // STEP 1: Try to restore directory access from stored bookmarks crate::logging::write_crash_debug_log("STEP 1: Attempting bookmark restoration"); let bookmark_restored = crate::macos_file_access::macos_file_handler::restore_directory_access_for_path(&path_str); if bookmark_restored { crate::logging::write_crash_debug_log("STEP 1: ✅ Bookmark restored, trying NSURL directory read"); if let Some(file_paths) = crate::macos_file_access::macos_file_handler::read_directory_with_security_scoped_url(&path_str) { return convert_file_paths_to_image_paths(file_paths); } else { crate::logging::write_crash_debug_log("STEP 1: ❌ NSURL directory read failed"); } } else { crate::logging::write_crash_debug_log("STEP 1: ❌ No bookmark found or restoration failed"); } // STEP 2: Check if this is an "Open With" scenario let accessible_paths = crate::macos_file_access::macos_file_handler::get_accessible_paths(); let has_individual_file_access = accessible_paths .iter() .any(|key| { let key_path = std::path::Path::new(key); key_path.is_file() && key_path.parent() .map(|parent| parent.to_string_lossy() == path_str) .unwrap_or(false) }); if has_individual_file_access { crate::logging::write_crash_debug_log("STEP 2: ✅ Confirmed 'Open With' scenario - requesting permission"); debug!("Confirmed 'Open With' scenario"); return request_directory_access_and_retry(directory_path, original_error); } else { crate::logging::write_crash_debug_log("STEP 2: ❌ Not an 'Open With' scenario - regular directory access failure"); debug!("Not an 'Open With' scenario - regular directory access failure"); return Err(ImageError::DirectoryError(original_error)); } } /// Convert file paths from security-scoped URL reading to image paths #[cfg(target_os = "macos")] fn convert_file_paths_to_image_paths( file_paths: Vec ) -> Result, ImageError> { let mut image_paths = Vec::new(); for file_path in file_paths { let path = std::path::Path::new(&file_path); if let Some(extension) = path.extension() { if let Some(ext_str) = extension.to_str() { if is_supported_extension(ext_str) { image_paths.push(path.to_path_buf()); } } } } if image_paths.is_empty() { crate::logging::write_crash_debug_log("❌ No image files found in security-scoped directory"); Err(ImageError::NoImagesFound) } else { crate::logging::write_crash_debug_log(&format!("✅ Found {} image files via security-scoped access", image_paths.len())); // Sort paths for consistent ordering alphanumeric_sort::sort_path_slice(&mut image_paths); Ok(image_paths) } } // ============================================================================ // Async Directory Enumeration (Issue #73 - NFS Performance Fix) // ============================================================================ use crate::app::{DirectoryEnumResult, DirectoryEnumError}; /// Async directory enumeration for non-blocking UI /// Uses tokio::fs for async I/O to prevent UI freezes on slow filesystems (NFS) pub async fn enumerate_directory_async(path: PathBuf) -> Result { use tokio::fs as async_fs; // Determine if path is a file or directory (sync metadata check is fast) let (dir_path, is_file_drop) = if is_file(&path) { let parent = path.parent() .ok_or(DirectoryEnumError::NotFound)? .to_path_buf(); (parent, true) } else if is_directory(&path) { (path.clone(), false) } else { return Err(DirectoryEnumError::NotFound); }; // Async directory enumeration let mut entries = async_fs::read_dir(&dir_path) .await .map_err(|e| DirectoryEnumError::DirectoryError(e.to_string()))?; let mut image_paths: Vec = Vec::new(); while let Some(entry) = entries.next_entry().await .map_err(|e| DirectoryEnumError::DirectoryError(e.to_string()))? { let entry_path = entry.path(); if let Some(extension) = entry_path.extension().and_then(std::ffi::OsStr::to_str) { if is_supported_extension(extension) { image_paths.push(entry_path); } } } if image_paths.is_empty() { return Err(DirectoryEnumError::NoImagesFound); } // Sort paths for consistent ordering alphanumeric_sort::sort_path_slice(&mut image_paths); // Calculate initial index for file drops let initial_index = if is_file_drop { get_file_index(&image_paths, &path).unwrap_or(0) } else { 0 }; Ok(DirectoryEnumResult { file_paths: image_paths, directory_path: dir_path.to_string_lossy().to_string(), initial_index, }) } ================================================ FILE: src/loading_handler.rs ================================================ #[allow(unused_imports)] use log::{debug, error, warn, info}; use crate::Arc; use crate::pane; use crate::loading_status::LoadingStatus; use crate::cache::img_cache::{LoadOperation, LoadOperationType, ImageMetadata}; use crate::cache::img_cache::CachedData; use crate::widgets::shader::scene::Scene; pub fn handle_load_operation_all( panes: &mut [pane::Pane], loading_status: &mut LoadingStatus, pane_indices: &[usize], target_indices: &[Option], image_data: &[Option], metadata: &[Option], op: &LoadOperation, operation_type: LoadOperationType, ) { info!("Handling load operation"); loading_status.being_loaded_queue.pop_front(); // Early return for Shift operations as they don't involve loading if matches!(operation_type, LoadOperationType::ShiftNext | LoadOperationType::ShiftPrevious) { return; } let mut panes_to_load: Vec<&mut pane::Pane> = panes.iter_mut() .enumerate() .filter_map(|(pane_index, pane)| { if pane.dir_loaded && pane.is_selected && pane_indices.contains(&pane_index) { Some(pane) } else { None } }) .collect(); for (pane_index, pane) in panes_to_load.iter_mut().enumerate() { info!("Loading pane {}", pane_index); let cache = &mut pane.img_cache; let target_index = match &target_indices[pane_index] { Some(index) => *index, None => continue, }; if cache.is_operation_blocking(operation_type) { return; } let target_image_to_load = match operation_type { LoadOperationType::LoadNext => Some(cache.get_next_image_to_load() as isize), LoadOperationType::LoadPrevious => Some(cache.get_prev_image_to_load() as isize), _ => None, }; if let Some(target_image_to_load) = target_image_to_load { if target_image_to_load == target_index { // Convert `Option>` to `Option` let mut converted_data = match image_data[pane_index].clone() { Some(CachedData::Cpu(data)) => { debug!("Cpu data"); Some(CachedData::Cpu(data)) } Some(CachedData::Gpu(texture)) => { debug!("Gpu texture"); Some(CachedData::Gpu(Arc::clone(&texture))) } Some(CachedData::BC1(texture)) => { debug!("BC1 texture"); Some(CachedData::BC1(Arc::clone(&texture))) } None => None, }; // Get metadata for this pane let converted_metadata = metadata.get(pane_index).cloned().flatten(); match op { LoadOperation::LoadNext(..) => { cache.move_next(converted_data.take(), converted_metadata, target_index).unwrap(); } LoadOperation::LoadPrevious(..) => { cache.move_prev(converted_data.take(), converted_metadata, target_index).unwrap(); } LoadOperation::ShiftNext(..) => { cache.move_next_edge(converted_data.take(), target_index).unwrap(); } LoadOperation::ShiftPrevious(..) => { cache.move_prev_edge(converted_data.take(), target_index).unwrap(); } LoadOperation::LoadPos((_, ref _target_indices_and_cache)) => { // LoadPos is covered in `handle_load_pos_operation()` } } // Reload current image if necessary if let Ok(cached_image) = cache.get_initial_image() { // Track which index this image represents pane.current_image_index = Some(cache.current_index); // Update current image metadata pane.current_image_metadata = cache.get_initial_metadata().cloned(); match cached_image { CachedData::Cpu(data) => { info!("Setting CPU image as current_image"); pane.current_image = CachedData::Cpu(data.clone()); pane.scene = Some(Scene::new(Some(&CachedData::Cpu(data.clone())))); // Ensure texture is created immediately to avoid black screens if let Some(device) = &pane.device { if let Some(queue) = &pane.queue { if let Some(scene) = &mut pane.scene { debug!("Ensuring texture is created for loaded image"); scene.ensure_texture(device, queue, pane.pane_id); } } } else { warn!("Cannot create texture: device or queue not available"); } } CachedData::Gpu(texture) => { debug!("Setting GPU texture as current_image"); pane.current_image = CachedData::Gpu(Arc::clone(texture)); pane.scene = Some(Scene::new(Some(&CachedData::Gpu(Arc::clone(texture))))); } CachedData::BC1(texture) => { info!("Setting BC1 compressed texture as current_image"); pane.current_image = CachedData::BC1(Arc::clone(texture)); pane.scene = Some(Scene::new(Some(&CachedData::BC1(Arc::clone(texture))))); // Ensure texture is created immediately to avoid black screens if let Some(scene) = &mut pane.scene { if let (Some(device), Some(queue)) = (&pane.device, &pane.queue) { scene.ensure_texture(device, queue, pane.pane_id); } } } } } } } } } pub fn handle_load_pos_operation( panes: &mut [pane::Pane], loading_status: &mut LoadingStatus, pane_index: usize, target_indices_and_cache: &[Option<(isize, usize)>], image_data: &[Option], metadata: &[Option], ) { debug!("===== Handling LoadPos operation ====="); // Remove the current LoadPos operation from the being_loaded queue loading_status.being_loaded_queue.pop_front(); // Log (target_index, cache_pos) pairs let mut processed_indices: Vec<(isize, usize)> = Vec::new(); // Access the pane that needs to update its cache and images if let Some(pane) = panes.get_mut(pane_index) { let cache = &mut pane.img_cache; // Iterate over the target indices and cache positions along with image data and metadata for (i, (target_opt, image_data_opt)) in target_indices_and_cache.iter().zip(image_data.iter()).enumerate() { debug!("Target index and cache position: {:?}", target_opt); if let Some((target_index, cache_pos)) = target_opt { processed_indices.extend(*target_opt); let target_index_usize = *target_index as usize; // Ensure that the target index is within valid bounds if target_index_usize < cache.image_paths.len() { // Load the image data into the cache if available if let Some(image) = image_data_opt { // Store the loaded image data in the cache at the specified cache position match image { CachedData::Cpu(data) => { cache.set_cached_data(*cache_pos, CachedData::Cpu(data.clone())); } CachedData::Gpu(texture) => { cache.set_cached_data(*cache_pos, CachedData::Gpu(Arc::clone(texture))); } CachedData::BC1(texture) => { info!("Creating BC1 compressed texture for scene"); cache.set_cached_data(*cache_pos, CachedData::BC1(Arc::clone(texture))); } } // Also store metadata if available if let Some(Some(meta)) = metadata.get(i) { cache.set_cached_metadata(*cache_pos, meta.clone()); } if cache.current_index == target_index_usize { // Reload current image if necessary debug!("LoadPos: target_index {} matches current_index {}, updating current_image", target_index_usize, cache.current_index); if let Ok(cached_image) = cache.get_initial_image() { // Track which index this image represents pane.current_image_index = Some(cache.current_index); // Update current image metadata pane.current_image_metadata = cache.get_initial_metadata().cloned(); match cached_image { CachedData::Cpu(data) => { debug!("Setting CPU image as current_image"); pane.current_image = CachedData::Cpu(data.clone()); pane.scene = Some(Scene::new(Some(&CachedData::Cpu(data.clone())))); } CachedData::Gpu(texture) => { debug!("Setting GPU texture as current_image"); pane.current_image = CachedData::Gpu(Arc::clone(texture)); pane.scene = Some(Scene::new(Some(&CachedData::Gpu(Arc::clone(texture))))); } CachedData::BC1(texture) => { info!("Setting BC1 compressed texture as current_image"); pane.current_image = CachedData::BC1(Arc::clone(texture)); pane.scene = Some(Scene::new(Some(&CachedData::BC1(Arc::clone(texture))))); // Ensure texture is created immediately to avoid black screens if let Some(scene) = &mut pane.scene { if let (Some(device), Some(queue)) = (&pane.device, &pane.queue) { scene.ensure_texture(device, queue, pane.pane_id); } } } } } } } else { debug!("No image data available for target index: {}", target_index); } } else { debug!("Target index {} is out of bounds", target_index); } } } debug!("LoadPos: Processed indices: {:?}", processed_indices); debug!("LoadPos: After processing, pane[{}].current_index = {}", pane_index, cache.current_index); } } ================================================ FILE: src/loading_status.rs ================================================ use crate::cache::img_cache::{LoadOperation, LoadOperationType}; use std::collections::VecDeque; use crate::pane::Pane; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; #[derive(Debug, Clone, PartialEq)] pub struct LoadingStatus { pub loading_queue: VecDeque, pub being_loaded_queue: VecDeque, // Queue of image indices being loaded pub out_of_order_images: Vec<(usize, Vec)>, pub is_next_image_loaded: bool, // whether the next image in cache is loaded pub is_prev_image_loaded: bool, // whether the previous image in cache is loaded } impl Default for LoadingStatus { fn default() -> Self { Self::new() } } // Edited in the duplicated vscode worksapce window impl LoadingStatus { pub fn new() -> Self { Self { loading_queue: VecDeque::new(), being_loaded_queue: VecDeque::new(), out_of_order_images: Vec::new(), is_next_image_loaded: false, // global flag, whether the next images in all the panes' cache are loaded is_prev_image_loaded: false, } } pub fn print_queue(&self) { debug!("loading_queue: {:?}", self.loading_queue); debug!("being_loaded_queue: {:?}", self.being_loaded_queue); } pub fn enqueue_image_load(&mut self, operation: LoadOperation) { self.loading_queue.push_back(operation); } pub fn reset_image_load_queue(&mut self) { self.loading_queue.clear(); } pub fn enqueue_image_being_loaded(&mut self, operation: LoadOperation) { self.being_loaded_queue.push_back(operation); } pub fn reset_image_being_loaded_queue(&mut self) { self.being_loaded_queue.clear(); } pub fn reset_load_next_queue_items(&mut self) { // Discard all queue items that are LoadNext or ShiftNext self.loading_queue.retain(|op| !matches!( op, LoadOperation::LoadNext(..) | LoadOperation::ShiftNext(..))); } pub fn reset_load_previous_queue_items(&mut self) { // Discard all queue items that are LoadPrevious or ShiftPrevious self.loading_queue.retain(|op| !matches!( op, LoadOperation::LoadPrevious(..) | LoadOperation::ShiftPrevious(..))); } #[allow(dead_code)] pub fn is_next_image_index_in_queue(&self, _cache_index: usize, next_image_index: isize) -> bool { // Check both the loading queue and being-loaded queue self.loading_queue.iter().all(|op| match op { LoadOperation::LoadNext((_c_index, img_indices)) | LoadOperation::LoadPrevious((_c_index, img_indices)) | LoadOperation::ShiftNext((_c_index, img_indices)) | LoadOperation::ShiftPrevious((_c_index, img_indices)) => { !img_indices.contains(&Some(next_image_index)) } LoadOperation::LoadPos((_c_index, target_indices_and_cache)) => { !target_indices_and_cache .iter() .any(|&item| item.map(|(index, _)| index == next_image_index).unwrap_or(false)) } }) && self.being_loaded_queue.iter().all(|op| match op { LoadOperation::LoadNext((_c_index, img_indices)) | LoadOperation::LoadPrevious((_c_index, img_indices)) | LoadOperation::ShiftNext((_c_index, img_indices)) | LoadOperation::ShiftPrevious((_c_index, img_indices)) => { !img_indices.contains(&Some(next_image_index)) } LoadOperation::LoadPos((_c_index, target_indices_and_cache)) => { !target_indices_and_cache .iter() .any(|&item| item.map(|(index, _)| index == next_image_index).unwrap_or(false)) } }) } pub fn are_next_image_indices_in_queue(&self, next_image_indices: &[Option]) -> bool { self.loading_queue.iter().all(|op| match op { LoadOperation::LoadNext((_c_index, img_indices)) | LoadOperation::LoadPrevious((_c_index, img_indices)) | LoadOperation::ShiftNext((_c_index, img_indices)) | LoadOperation::ShiftPrevious((_c_index, img_indices)) => { img_indices != next_image_indices } LoadOperation::LoadPos((_c_index, target_indices_and_cache)) => { let extracted_indices: Vec> = target_indices_and_cache .iter() .map(|item| item.map(|(index, _)| index)) .collect(); &extracted_indices[..] != next_image_indices } }) && self.being_loaded_queue.iter().all(|op| match op { LoadOperation::LoadNext((_c_index, img_indices)) | LoadOperation::LoadPrevious((_c_index, img_indices)) | LoadOperation::ShiftNext((_c_index, img_indices)) | LoadOperation::ShiftPrevious((_c_index, img_indices)) => { img_indices != next_image_indices } LoadOperation::LoadPos((_c_index, target_indices_and_cache)) => { let extracted_indices: Vec> = target_indices_and_cache .iter() .map(|item| item.map(|(index, _)| index)) .collect(); &extracted_indices[..] != next_image_indices } }) } /// If there are certain loading operations in the queue and the new loading op would cause bugs, return true /// e.g. When current_offset==5 and LoadPrevious op is at the head of the queue(queue.front()), /// the new op is LoadNext: this would make current_offset==6 and cache would be out of bounds pub fn is_blocking_loading_ops_in_queue(&self, panes: &mut Vec<&mut Pane>, loading_operation: &LoadOperation) -> bool { for pane in panes { let img_cache = &pane.img_cache; if img_cache.is_blocking_loading_ops_in_queue(loading_operation.clone(), self) { return true; } } false } pub fn is_operation_in_queues(&self, operation: LoadOperationType) -> bool { self.loading_queue.iter().any(|op| op.operation_type() == operation) || self.being_loaded_queue.iter().any(|op| op.operation_type() == operation) } } ================================================ FILE: src/logging.rs ================================================ /* ================================================================================ ViewSkater Logging System ================================================================================ This module provides comprehensive logging infrastructure for ViewSkater, handling both standard application logging and low-level crash diagnostics. It contains two main functional groups: ## 1. Standard Application Logging **Purpose**: Normal application logging using Rust's `log` crate (debug!, info!, etc.) **Components**: - `BufferLogger`: Captures log messages in memory buffer for export - `CompositeLogger`: Combines console output with buffer capture - `setup_logger()`: Initializes the logging system with appropriate filters - `setup_panic_hook()`: Handles Rust panics with detailed backtraces - `export_debug_logs()`: Exports captured log messages to debug.log - `setup_stdout_capture()`: Captures println! output for export (Unix only) - `export_stdout_logs()`: Exports captured stdout to stdout.log **Log Levels**: - Debug builds: Shows DEBUG and above - Release builds: Shows ERROR only (unless RUST_LOG is set) - All logs are captured in circular buffer (last 1000 entries) ## 2. Low-Level Crash Diagnostics **Purpose**: Crash logging that works even when Rust panic handling fails. When Objective-C code crashes, it bypasses Rust's panic system and goes straight to Unix signals. **Use Cases**: - Objective-C interop crashes (segfaults, bus errors) - App Store sandbox crashes where console isn't available - Signal-level crashes that bypass normal Rust error handling **Components**: - `write_crash_debug_log()`: Multi-method crash logging (stderr, stdout, NSUserDefaults) - `write_immediate_crash_log()`: Synchronous disk logging with maximum reliability - `setup_signal_crash_handler()`: Unix signal handler for SIGSEGV, SIGBUS, etc. - `get_crash_debug_logs_from_userdefaults()`: Retrieve crash logs from macOS preferences **Storage Methods**: 1. **Immediate file writes**: Multiple locations with O_SYNC for reliability 2. **NSUserDefaults**: macOS preferences system (survives crashes) 3. **Console output**: stderr/stdout for development debugging ## 3. Log Export & Management **User-Facing Features**: - "Export debug logs": Exports log buffer to debug.log - "Export all logs": Exports both debug and stdout logs - "Show logs": Opens log directory in file explorer - Automatic log directory creation and management **File Locations**: - macOS: ~/Library/Application Support/viewskater/logs/ - Other: Uses dirs crate for appropriate data directory ## Thread Safety All shared state is protected by Mutex: - Log buffers use Arc>> - Circular buffer management prevents memory growth - Signal handlers use minimal, async-signal-safe operations ## Platform Differences **macOS**: NSUserDefaults integration + Unix signal handling + pipe-based stdout capture **Linux**: Unix signal handling + O_SYNC file writes + pipe-based stdout capture **Windows**: File-based crash logging + manual stdout capture (no signal handling) ================================================================================ */ use std::panic; use std::fs::OpenOptions; use std::io::Write; use std::sync::{Arc, Mutex}; use std::collections::VecDeque; use std::path::PathBuf; use std::process::Command; use once_cell::sync::Lazy; use env_logger::fmt::Color; use log::{LevelFilter, Metadata, Record}; use env_logger::fmt::Formatter; use chrono::Utc; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; const MAX_LOG_LINES: usize = 1000; // Global buffer for stdout capture static STDOUT_BUFFER: Lazy>>> = Lazy::new(|| { Arc::new(Mutex::new(VecDeque::with_capacity(1000))) }); // Global flag to control stdout capture #[allow(dead_code)] static STDOUT_CAPTURE_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); struct BufferLogger { log_buffer: Arc>>, } impl BufferLogger { #[allow(dead_code)] fn new() -> Self { Self { log_buffer: Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_LINES))), } } fn log_to_buffer(&self, message: &str, target: &str, line: Option, _module_path: Option<&str>) { if target.starts_with("viewskater") { let mut buffer = self.log_buffer.lock().unwrap(); if buffer.len() == MAX_LOG_LINES { buffer.pop_front(); } // Format the log message to include only line number to avoid duplication // The module is already in the target in most cases let formatted_message = if let Some(line_num) = line { format!("{}:{} {}", target, line_num, message) } else { format!("{} {}", target, message) }; buffer.push_back(formatted_message); } } #[allow(dead_code)] fn dump_logs(&self) -> Vec { let buffer = self.log_buffer.lock().unwrap(); buffer.iter().cloned().collect() } #[allow(dead_code)] fn get_shared_buffer(&self) -> Arc>> { Arc::clone(&self.log_buffer) } } impl log::Log for BufferLogger { fn enabled(&self, metadata: &Metadata) -> bool { metadata.target().starts_with("viewskater") && metadata.level() <= LevelFilter::Debug } fn log(&self, record: &Record) { if self.enabled(record.metadata()) { let message = format!("{:<5} {}", record.level(), record.args()); self.log_to_buffer( &message, record.target(), record.line(), record.module_path() ); } } fn flush(&self) {} } #[allow(dead_code)] struct CompositeLogger { console_logger: env_logger::Logger, buffer_logger: BufferLogger, } impl log::Log for CompositeLogger { fn enabled(&self, metadata: &Metadata) -> bool { self.console_logger.enabled(metadata) || self.buffer_logger.enabled(metadata) } fn log(&self, record: &Record) { if self.console_logger.enabled(record.metadata()) { self.console_logger.log(record); } if self.buffer_logger.enabled(record.metadata()) { self.buffer_logger.log(record); } } fn flush(&self) { self.console_logger.flush(); self.buffer_logger.flush(); } } #[allow(dead_code)] pub fn setup_logger(_app_name: &str) -> Arc>> { let buffer_logger = BufferLogger::new(); let shared_buffer = buffer_logger.get_shared_buffer(); let mut builder = env_logger::Builder::new(); // First check if RUST_LOG is set - if so, use that configuration if std::env::var("RUST_LOG").is_ok() { builder.parse_env("RUST_LOG"); } else { // If RUST_LOG is not set, use different defaults for debug/release builds if cfg!(debug_assertions) { // In debug mode, show debug logs and above builder.filter(Some("viewskater"), LevelFilter::Debug); } else { // In release mode, only show errors by default builder.filter(Some("viewskater"), LevelFilter::Error); } } // Filter out all other crates' logs builder.filter(None, LevelFilter::Off); builder.format(|buf: &mut Formatter, record: &Record| { let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ"); // Create the module:line part let module_info = if let (Some(module), Some(line)) = (record.module_path(), record.line()) { format!("{}:{}", module, line) } else if let Some(module) = record.module_path() { module.to_string() } else if let Some(line) = record.line() { format!("line:{}", line) } else { "unknown".to_string() }; let mut level_style = buf.style(); let mut meta_style = buf.style(); // Set level colors match record.level() { Level::Error => level_style.set_color(Color::Red).set_bold(true), Level::Warn => level_style.set_color(Color::Yellow).set_bold(true), Level::Info => level_style.set_color(Color::Green).set_bold(true), Level::Debug => level_style.set_color(Color::Blue).set_bold(true), Level::Trace => level_style.set_color(Color::White), }; // Set meta style color based on platform #[cfg(target_os = "macos")] { // Color::Rgb does not work on macOS, so we use Color::Blue as a workaround meta_style.set_color(Color::Blue); } #[cfg(not(target_os = "macos"))] { // Color formatting with Color::Rgb works fine on Windows/Linux meta_style.set_color(Color::Rgb(120, 120, 120)); } writeln!( buf, "{} {} {} {}", meta_style.value(timestamp), level_style.value(record.level()), meta_style.value(module_info), record.args() ) }); let console_logger = builder.build(); let composite_logger = CompositeLogger { console_logger, buffer_logger, }; log::set_boxed_logger(Box::new(composite_logger)).expect("Failed to set logger"); // Always set the maximum level to Trace so that filtering works correctly log::set_max_level(LevelFilter::Trace); shared_buffer } pub fn get_log_directory(app_name: &str) -> PathBuf { dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")).join(app_name).join("logs") } /// Exports the current log buffer to a debug log file. /// /// This function writes the last 1,000 lines of logs (captured via the log macros like debug!, info!, etc.) /// to a separate debug log file. This is useful for troubleshooting issues without waiting for a crash. /// /// NOTE: This currently captures logs from the Rust `log` crate macros (debug!, info!, warn!, error!) /// but does NOT capture raw `println!` statements. To capture println! statements, stdout redirection /// would be needed, which is more complex and may interfere with normal console output. /// /// # Arguments /// * `app_name` - The application name used for the log directory /// * `log_buffer` - The shared log buffer containing the recent log messages /// /// # Returns /// * `Ok(PathBuf)` - The path to the created debug log file /// * `Err(std::io::Error)` - An error if the export fails pub fn export_debug_logs(app_name: &str, log_buffer: Arc>>) -> Result { // NOTE: Use println! instead of debug! to avoid circular logging // (debug! calls would be added to the same buffer we're trying to export) println!("DEBUG: export_debug_logs called"); let log_dir_path = get_log_directory(app_name); println!("DEBUG: Log directory path: {}", log_dir_path.display()); std::fs::create_dir_all(&log_dir_path)?; println!("DEBUG: Created log directory"); let debug_log_path = log_dir_path.join("debug.log"); println!("DEBUG: Debug log path: {}", debug_log_path.display()); println!("DEBUG: About to open file for writing"); let mut file = OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&debug_log_path)?; println!("DEBUG: File opened successfully"); // Write formatted timestamp let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ"); println!("DEBUG: About to write header"); writeln!(file, "{} [DEBUG EXPORT] =====================================", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] ViewSkater Debug Log Export", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] Export timestamp: {}", timestamp, timestamp)?; writeln!(file, "{} [DEBUG EXPORT] =====================================", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] ", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] IMPORTANT: This log captures output from Rust log macros", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] (debug!, info!, warn!, error!) but NOT raw println! statements.", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] Maximum captured entries: {}", timestamp, MAX_LOG_LINES)?; writeln!(file)?; // Empty line for readability println!("DEBUG: Header written"); // Export all log entries from the buffer println!("DEBUG: About to lock log buffer"); let buffer_size; let buffer_empty; let log_entries: Vec; { let buffer = log_buffer.lock().unwrap(); println!("DEBUG: Log buffer locked, size: {}", buffer.len()); buffer_size = buffer.len(); buffer_empty = buffer.is_empty(); log_entries = buffer.iter().cloned().collect(); println!("DEBUG: Copied {} entries, releasing lock", buffer_size); } // Lock is dropped here println!("DEBUG: Buffer lock released"); if buffer_empty { println!("DEBUG: Buffer is empty, writing empty message"); writeln!(file, "{} [DEBUG EXPORT] No log entries found in buffer", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] This may indicate that:", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] 1. No log macros have been called yet", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] 2. All logs were filtered out by log level settings", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] 3. The app just started and no logs have been generated", timestamp)?; } else { println!("DEBUG: Writing {} log entries", buffer_size); writeln!(file, "{} [DEBUG EXPORT] Found {} log entries (showing last {} max):", timestamp, buffer_size, MAX_LOG_LINES)?; writeln!(file, "{} [DEBUG EXPORT] =====================================", timestamp)?; writeln!(file)?; // Empty line for readability for log_entry in log_entries.iter() { writeln!(file, "{} {}", timestamp, log_entry)?; } println!("DEBUG: All entries written"); } println!("DEBUG: Writing footer"); writeln!(file)?; // Final empty line writeln!(file, "{} [DEBUG EXPORT] =====================================", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] Export completed successfully", timestamp)?; writeln!(file, "{} [DEBUG EXPORT] Total entries exported: {}", timestamp, buffer_size)?; writeln!(file, "{} [DEBUG EXPORT] =====================================", timestamp)?; println!("DEBUG: About to flush file"); file.flush()?; println!("DEBUG: File flushed"); println!("DEBUG: About to call info! macro"); info!("Debug logs exported to: {}", debug_log_path.display()); println!("DEBUG: info! macro completed"); println!("DEBUG: export_debug_logs completed successfully"); Ok(debug_log_path) } /// Exports debug logs and opens the log directory in the file explorer. /// /// This is a convenience function that combines exporting debug logs and opening /// the log directory for easy access to the exported files. /// /// # Arguments /// * `app_name` - The application name used for the log directory /// * `log_buffer` - The shared log buffer containing the recent log messages pub fn export_and_open_debug_logs(app_name: &str, log_buffer: Arc>>) { // NOTE: Use println! to avoid circular logging during export operations println!("DEBUG: About to export debug logs..."); if let Ok(buffer) = log_buffer.lock() { println!("DEBUG: Buffer size at export time: {}", buffer.len()); if !buffer.is_empty() { println!("DEBUG: First few entries:"); for (i, entry) in buffer.iter().take(3).enumerate() { println!("DEBUG: Entry {}: {}", i, entry); } } } match export_debug_logs(app_name, log_buffer) { Ok(debug_log_path) => { info!("Debug logs successfully exported to: {}", debug_log_path.display()); println!("Debug logs exported to: {}", debug_log_path.display()); // Temporarily disable automatic directory opening to prevent hangs // let log_dir = debug_log_path.parent().unwrap_or_else(|| Path::new(".")); // open_in_file_explorer(&log_dir.to_string_lossy().to_string()); } Err(e) => { error!("Failed to export debug logs: {}", e); eprintln!("Failed to export debug logs: {}", e); } } } pub fn setup_panic_hook(app_name: &str, log_buffer: Arc>>) { let log_file_path = get_log_directory(app_name).join("panic.log"); std::fs::create_dir_all(log_file_path.parent().unwrap()).expect("Failed to create log directory"); panic::set_hook(Box::new(move |info| { let backtrace = backtrace::Backtrace::new(); let mut file = OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&log_file_path) .expect("Failed to open panic log file"); // Write formatted timestamp let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ"); // Extract panic location information if available let location = if let Some(location) = info.location() { format!("{}:{}", location.file(), location.line()) } else { "unknown location".to_string() }; // Create formatted messages that we'll use for both console and file let header_msg = format!("[PANIC] at {} - {}", location, info); let backtrace_header = "[PANIC] Backtrace:"; // Format backtrace lines let mut backtrace_lines = Vec::new(); for line in format!("{:?}", backtrace).lines() { backtrace_lines.push(format!("[BACKTRACE] {}", line.trim())); } // Log header to file writeln!(file, "{} {}", timestamp, header_msg).expect("Failed to write panic info"); writeln!(file, "{} {}", timestamp, backtrace_header).expect("Failed to write backtrace header"); // Log backtrace to file for line in &backtrace_lines { writeln!(file, "{} {}", timestamp, line).expect("Failed to write backtrace line"); } // Add double linebreak between backtrace and log entries writeln!(file).expect("Failed to write newline"); writeln!(file).expect("Failed to write second newline"); // Dump the last N log lines from the buffer with timestamps writeln!(file, "{} [PANIC] Last {} log entries:", timestamp, MAX_LOG_LINES) .expect("Failed to write log header"); let buffer = log_buffer.lock().unwrap(); for log in buffer.iter() { writeln!(file, "{} {}", timestamp, log).expect("Failed to write log entry"); } // ALSO PRINT TO CONSOLE (this is the new part) // Use eprintln! to print to stderr eprintln!("\n\n{}", header_msg); eprintln!("{}", backtrace_header); for line in &backtrace_lines { eprintln!("{}", line); } eprintln!("\nA complete crash log has been written to: {}", log_file_path.display()); })); } pub fn open_in_file_explorer(path: &str) { if cfg!(target_os = "windows") { // Windows: Use "explorer" to open the directory match Command::new("explorer") .arg(path) .spawn() { Ok(_) => println!("Opened directory in File Explorer: {}", path), Err(e) => eprintln!("Failed to open directory in File Explorer: {}", e), } } else if cfg!(target_os = "macos") { // macOS: Use "open" to open the directory match Command::new("open") .arg(path) .spawn() { Ok(_) => println!("Opened directory in Finder: {}", path), Err(e) => eprintln!("Failed to open directory in Finder: {}", e), } } else if cfg!(target_os = "linux") { // Linux: Use "xdg-open" to open the directory (works with most desktop environments) match Command::new("xdg-open") .arg(path) .spawn() { Ok(_) => println!("Opened directory in File Explorer: {}", path), Err(e) => eprintln!("Failed to open directory in File Explorer: {}", e), } } else { error!("Opening directories is not supported on this OS."); } } /// Sets up stdout capture using Unix pipes to intercept println! and other stdout output. /// /// This function creates a pipe, redirects stdout to the write end of the pipe, /// and spawns a thread to read from the read end and capture the output. /// /// # Returns /// * `Arc>>` - The shared stdout buffer #[cfg(unix)] pub fn setup_stdout_capture() -> Arc>> { use std::os::unix::io::FromRawFd; use std::fs::File; use std::io::{BufReader, BufRead}; use std::thread; // Create a pipe let mut pipe_fds = [0i32; 2]; unsafe { if libc::pipe(pipe_fds.as_mut_ptr()) != 0 { eprintln!("Failed to create pipe for stdout capture"); return Arc::clone(&STDOUT_BUFFER); } } let read_fd = pipe_fds[0]; let write_fd = pipe_fds[1]; // Duplicate the original stdout so we can restore it later let original_stdout_fd = unsafe { libc::dup(libc::STDOUT_FILENO) }; if original_stdout_fd == -1 { eprintln!("Failed to duplicate original stdout"); unsafe { libc::close(read_fd); libc::close(write_fd); } return Arc::clone(&STDOUT_BUFFER); } // Redirect stdout to the write end of the pipe unsafe { if libc::dup2(write_fd, libc::STDOUT_FILENO) == -1 { eprintln!("Failed to redirect stdout to pipe"); libc::close(read_fd); libc::close(write_fd); libc::close(original_stdout_fd); return Arc::clone(&STDOUT_BUFFER); } } // Create a file from the read end of the pipe let pipe_reader = unsafe { File::from_raw_fd(read_fd) }; let mut buf_reader = BufReader::new(pipe_reader); // Create a writer for the original stdout let original_stdout = unsafe { File::from_raw_fd(original_stdout_fd) }; // Enable stdout capture STDOUT_CAPTURE_ENABLED.store(true, std::sync::atomic::Ordering::SeqCst); // Clone the buffer for the thread let buffer = Arc::clone(&STDOUT_BUFFER); // Spawn a thread to read from the pipe and capture output thread::spawn(move || { let mut line = String::new(); let mut original_stdout = original_stdout; while STDOUT_CAPTURE_ENABLED.load(std::sync::atomic::Ordering::SeqCst) { line.clear(); match buf_reader.read_line(&mut line) { Ok(0) => break, // EOF Ok(_) => { let trimmed = line.trim(); if !trimmed.is_empty() { // Write to original stdout (console) let _ = writeln!(original_stdout, "{}", trimmed); let _ = original_stdout.flush(); // Capture to buffer if let Ok(mut buffer) = buffer.lock() { if buffer.len() >= 1000 { buffer.pop_front(); } let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ"); buffer.push_back(format!("{} [STDOUT] {}", timestamp, trimmed)); } } } Err(_) => break, } } }); // Close the write end of the pipe in this process (the duplicated stdout will handle writing) unsafe { libc::close(write_fd); } // Add initialization message to buffer if let Ok(mut buf) = STDOUT_BUFFER.lock() { let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ"); buf.push_back(format!("{} [STDOUT] ViewSkater stdout capture initialized", timestamp)); } // This println! should now be captured println!("Stdout capture initialized - all println! statements will be captured"); Arc::clone(&STDOUT_BUFFER) } /// Sets up stdout capture (Windows/non-Unix fallback - manual capture only) /// /// This function provides a fallback for non-Unix systems where stdout redirection /// is more complex. It uses manual capture only. /// /// # Returns /// * `Arc>>` - The shared stdout buffer #[cfg(not(unix))] pub fn setup_stdout_capture() -> Arc>> { // Add initialization message to buffer if let Ok(mut buf) = STDOUT_BUFFER.lock() { let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ"); buf.push_back(format!("{} [STDOUT] ViewSkater stdout capture initialized", timestamp)); } println!("Stdout capture initialized (manual mode) - use capture_stdout() for important messages"); Arc::clone(&STDOUT_BUFFER) } /// Exports stdout logs to a separate file. /// /// This function writes the captured stdout output (from println! and other stdout writes) /// to a separate stdout log file. This complements the debug log export. /// /// # Arguments /// * `app_name` - The application name used for the log directory /// * `stdout_buffer` - The shared stdout buffer containing captured output /// /// # Returns /// * `Ok(PathBuf)` - The path to the created stdout log file /// * `Err(std::io::Error)` - An error if the export fails pub fn export_stdout_logs(app_name: &str, stdout_buffer: Arc>>) -> Result { let log_dir_path = get_log_directory(app_name); std::fs::create_dir_all(&log_dir_path)?; let stdout_log_path = log_dir_path.join("stdout.log"); let mut file = OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&stdout_log_path)?; // Write formatted timestamp let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ"); writeln!(file, "{} [STDOUT EXPORT] =====================================", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] ViewSkater Stdout Log Export", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] Export timestamp: {}", timestamp, timestamp)?; writeln!(file, "{} [STDOUT EXPORT] =====================================", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] ", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] This log captures stdout output including println! statements", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] Maximum captured entries: 1000", timestamp)?; writeln!(file)?; // Empty line for readability // Export all stdout entries from the buffer let buffer = stdout_buffer.lock().unwrap(); if buffer.is_empty() { writeln!(file, "{} [STDOUT EXPORT] No stdout entries found in buffer", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] Note: Automatic stdout capture is disabled", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] Use debug logs (debug!, info!, etc.) for logging instead", timestamp)?; } else { writeln!(file, "{} [STDOUT EXPORT] Found {} stdout entries:", timestamp, buffer.len())?; writeln!(file, "{} [STDOUT EXPORT] =====================================", timestamp)?; writeln!(file)?; // Empty line for readability for stdout_entry in buffer.iter() { writeln!(file, "{}", stdout_entry)?; } } writeln!(file)?; // Final empty line writeln!(file, "{} [STDOUT EXPORT] =====================================", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] Export completed successfully", timestamp)?; writeln!(file, "{} [STDOUT EXPORT] Total entries exported: {}", timestamp, buffer.len())?; writeln!(file, "{} [STDOUT EXPORT] =====================================", timestamp)?; file.flush()?; info!("Stdout logs exported to: {}", stdout_log_path.display()); println!("Stdout logs exported to: {}", stdout_log_path.display()); Ok(stdout_log_path) } /// Exports both debug logs and stdout logs, then opens the log directory. /// /// This is a convenience function that exports both types of logs and opens /// the log directory for easy access to all exported files. /// /// # Arguments /// * `app_name` - The application name used for the log directory /// * `log_buffer` - The shared log buffer containing recent log messages /// * `stdout_buffer` - The shared stdout buffer containing captured output pub fn export_and_open_all_logs(app_name: &str, log_buffer: Arc>>, stdout_buffer: Arc>>) { // NOTE: Use println! to avoid circular logging during export operations println!("DEBUG: About to export all logs..."); if let Ok(log_buf) = log_buffer.lock() { println!("DEBUG: Log buffer size: {}", log_buf.len()); } if let Ok(stdout_buf) = stdout_buffer.lock() { println!("DEBUG: Stdout buffer size: {}", stdout_buf.len()); } // Export debug logs match export_debug_logs(app_name, log_buffer) { Ok(debug_log_path) => { info!("Debug logs successfully exported to: {}", debug_log_path.display()); // Open the log directory in file explorer (using debug log path) let log_dir = debug_log_path.parent().unwrap_or_else(|| std::path::Path::new(".")); open_in_file_explorer(log_dir.to_string_lossy().as_ref()); } Err(e) => { error!("Failed to export debug logs: {}", e); eprintln!("Failed to export debug logs: {}", e); } } // Only export stdout logs if there's actually something in the buffer let should_export_stdout = { if let Ok(stdout_buf) = stdout_buffer.lock() { !stdout_buf.is_empty() } else { false } }; if should_export_stdout { match export_stdout_logs(app_name, stdout_buffer) { Ok(stdout_log_path) => { info!("Stdout logs successfully exported to: {}", stdout_log_path.display()); } Err(e) => { error!("Failed to export stdout logs: {}", e); eprintln!("Failed to export stdout logs: {}", e); } } } else { println!("Skipping stdout.log export - buffer is empty (stdout capture disabled)"); } } /// macOS integration for opening image files via Finder. /// /// This module handles cases where the user launches ViewSkater by double-clicking /// an image file or using "Open With" in Finder. macOS sends the file path through /// the `application:openFiles:` message, which is delivered to the app's delegate. /// /// This code: /// - Subclasses the existing `NSApplicationDelegate` to override `application:openFiles:` /// - Forwards received file paths to Rust using an MPSC channel /// - Disables automatic argument parsing by setting `NSTreatUnknownArgumentsAsOpen = NO` /// /// The channel is set up in `main.rs` and connected to the rest of the app so that /// the selected image can be loaded on startup. // ==================== CRASH DEBUG LOGGING ==================== /// Writes a crash debug log entry using multiple bulletproof methods for App Store sandbox /// This ensures we can see what happened even if all file writing is blocked pub fn write_crash_debug_log(message: &str) { // Simple immediate stderr logging let _ = std::panic::catch_unwind(|| { eprintln!("CRASH_DEBUG: {}", message); }); // Simple immediate stdout logging let _ = std::panic::catch_unwind(|| { println!("CRASH_DEBUG: {}", message); }); // Simple NSUserDefaults logging #[cfg(target_os = "macos")] { use objc2_foundation::{NSUserDefaults, NSString}; use objc2::{msg_send}; unsafe { let defaults = NSUserDefaults::standardUserDefaults(); let key = NSString::from_str("ViewSkaterLastCrashLog"); let value = NSString::from_str(message); let _: () = msg_send![&*defaults, setObject: &*value forKey: &*key]; } } } /// Writes crash debug info immediately to disk (synchronous, unbuffered) /// This is specifically for crashes during "Open With" startup where console isn't available #[cfg(target_os = "macos")] pub fn write_immediate_crash_log(message: &str) { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let formatted = format!("{} CRASH: {}\n", timestamp, message); // Use the same directory approach as file_io module let mut paths = Vec::new(); // Primary location: Use dirs crate like file_io does let app_log_dir = dirs::data_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("viewskater") .join("logs"); if std::fs::create_dir_all(&app_log_dir).is_ok() { paths.push(app_log_dir.join("crash.log")); } // Backup: Use cache directory if let Some(cache_dir) = dirs::cache_dir() { let cache_log_dir = cache_dir.join("viewskater"); if std::fs::create_dir_all(&cache_log_dir).is_ok() { paths.push(cache_log_dir.join("crash.log")); } } // Fallback: home directory if let Some(home) = dirs::home_dir() { paths.push(home.join("viewskater_crash.log")); } // Emergency fallback: /tmp paths.push("/tmp/viewskater_crash.log".into()); // Write to all available locations with MAXIMUM reliability for path in &paths { // Create options with immediate disk writes on Unix systems let mut options = std::fs::OpenOptions::new(); options.create(true).append(true); #[cfg(unix)] { { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(0x80); // O_SYNC on Unix - immediate disk writes } } if let Ok(mut file) = options.open(path) { let _ = file.write_all(formatted.as_bytes()); let _ = file.sync_all(); // Force filesystem sync let _ = file.sync_data(); // Force data sync (faster than sync_all) // Don't close - let it drop naturally to avoid blocking } } // ALSO write to NSUserDefaults immediately as backup #[cfg(target_os = "macos")] { let _ = std::panic::catch_unwind(|| { use objc2_foundation::{NSUserDefaults, NSString}; use objc2::{msg_send}; unsafe { let defaults = NSUserDefaults::standardUserDefaults(); let key = NSString::from_str("ViewSkaterImmediateCrashLog"); let value = NSString::from_str(&formatted); let _: () = msg_send![&*defaults, setObject: &*value forKey: &*key]; let _: bool = msg_send![&*defaults, synchronize]; } }); } } // ==================== END CRASH DEBUG LOGGING ==================== /// Retrieves crash debug logs from NSUserDefaults (bulletproof storage) - SIMPLIFIED VERSION /// This allows accessing logs even if file writing was blocked by App Store sandbox #[cfg(target_os = "macos")] pub fn get_crash_debug_logs_from_userdefaults() -> Vec { use objc2_foundation::{NSUserDefaults, NSString}; use objc2::{msg_send}; use objc2::rc::autoreleasepool; autoreleasepool(|pool| unsafe { let mut results = Vec::new(); let defaults = NSUserDefaults::standardUserDefaults(); // Get the crash counter let counter_key = NSString::from_str("ViewSkaterCrashCounter"); let crash_count: i64 = msg_send![&*defaults, integerForKey: &*counter_key]; results.push(format!("CRASH_COUNTER: {} crashes detected", crash_count)); // Get the last crash log let log_key = NSString::from_str("ViewSkaterLastCrashLog"); let last_log: *mut objc2::runtime::AnyObject = msg_send![&*defaults, objectForKey: &*log_key]; if !last_log.is_null() { let log_nsstring = &*(last_log as *const NSString); let log_str = log_nsstring.as_str(pool).to_owned(); results.push(format!("LAST_CRASH_LOG: {}", log_str)); } else { results.push("LAST_CRASH_LOG: No crash log found".to_string()); } results }) } /// Sets up a signal handler to catch low-level crashes that bypass Rust panic hooks /// This is critical for Objective-C interop crashes that might cause segfaults #[cfg(unix)] pub fn setup_signal_crash_handler() { extern "C" fn signal_handler(signal: libc::c_int) { let signal_name = match signal { libc::SIGSEGV => "SIGSEGV (segmentation fault)", libc::SIGBUS => "SIGBUS (bus error)", libc::SIGILL => "SIGILL (illegal instruction)", libc::SIGFPE => "SIGFPE (floating point exception)", libc::SIGABRT => "SIGABRT (abort)", _ => "UNKNOWN SIGNAL", }; // Use the most basic logging possible since we're in a signal handler let _ = std::panic::catch_unwind(|| { eprintln!("CRASH_DEBUG: SIGNAL CAUGHT: {} ({})", signal_name, signal); println!("CRASH_DEBUG: SIGNAL CAUGHT: {} ({})", signal_name, signal); }); // Try to write to NSUserDefaults if possible #[cfg(target_os = "macos")] { unsafe { use objc2_foundation::{NSUserDefaults, NSString}; use objc2::{msg_send}; let message = format!("SIGNAL_CRASH: {} ({})", signal_name, signal); let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ"); let formatted_message = format!("{} CRASH_DEBUG: {}", timestamp, message); let defaults = NSUserDefaults::standardUserDefaults(); let key = NSString::from_str("ViewSkaterLastCrashLog"); let value = NSString::from_str(&formatted_message); let _: () = msg_send![&*defaults, setObject: &*value forKey: &*key]; } } // Exit after logging std::process::exit(128 + signal); } unsafe { libc::signal(libc::SIGSEGV, signal_handler as libc::sighandler_t); libc::signal(libc::SIGBUS, signal_handler as libc::sighandler_t); libc::signal(libc::SIGILL, signal_handler as libc::sighandler_t); libc::signal(libc::SIGFPE, signal_handler as libc::sighandler_t); libc::signal(libc::SIGABRT, signal_handler as libc::sighandler_t); } } #[cfg(not(unix))] pub fn setup_signal_crash_handler() { // Signal handling not implemented for non-Unix platforms } ================================================ FILE: src/macos_file_access.rs ================================================ /* ================================================================================ macOS File Access & Security Bookmarks ================================================================================ This module handles file system access for macOS App Store builds in sandboxed environments. It provides two main functionalities: 1. **Finder Integration**: Handles files opened via "Open With" or double-click 2. **Security-Scoped Bookmarks**: Persistent directory access across app launches ## Why This is Needed When ViewSkater is distributed through the macOS App Store, it runs in a sandbox that restricts file system access. Users can open images via Finder's "Open With" menu, but the app needs special permission to browse the containing directory. ViewSkater requires parent directory access to browse all images in the folder and preload neighboring images for fast navigation between prev/next images. ## How Security-Scoped Bookmarks Work 1. **User Opens Image**: Via Finder → "Open With" → ViewSkater 2. **Individual File Access**: macOS grants access to that specific image file 3. **Directory Access Needed**: To browse other images in the same folder 4. **Permission Dialog**: App shows optimized NSOpenPanel for directory access 5. **Bookmark Creation**: App creates a security-scoped bookmark for the directory 6. **Persistent Storage**: Bookmark is stored in NSUserDefaults for future use 7. **Future Access**: App can restore directory access from stored bookmark ## Key Components ### Security-Scoped URLs - `NSURL` objects with special security scope permissions - Must call `startAccessingSecurityScopedResource()` to activate - Must call `stopAccessingSecurityScopedResource()` when done - Only valid for the session unless converted to bookmarks ### Security-Scoped Bookmarks - Persistent data that can recreate security-scoped URLs - Stored in NSUserDefaults with keys like "VSBookmark|/path/to/directory" - Can become "stale" and need refreshing - Created with `NSURLBookmarkCreationWithSecurityScope` flag - Resolved with `NSURLBookmarkResolutionWithSecurityScope` flag ### Session-Level Caching - `SESSION_RESOLVED_URLS`: Caches resolved URLs per session - Prevents multiple `URLByResolvingBookmarkData` calls for same directory - Critical for performance and avoiding macOS API limitations ## Workflow for "Open With" Scenario ``` User opens image.jpg via Finder ↓ handle_opened_file() receives file path ↓ App gets individual file access automatically ↓ App tries to access parent directory: → SANDBOXED (App Store): FAILS - needs explicit permission → LOCAL BUILD: SUCCESS - direct access ✓ ↓ [SANDBOXED PATH ONLY] restore_directory_access_for_path() checks for stored bookmark ↓ If bookmark exists: - Resolve bookmark to security-scoped URL - Call startAccessingSecurityScopedResource() - Directory access granted ✓ ↓ If no bookmark: - Show optimized permission dialog (NSOpenPanel) - User clicks "Allow Access" - Create security-scoped bookmark - Store bookmark in NSUserDefaults - Directory access granted ✓ ``` ## Critical Constants - `NSURL_BOOKMARK_CREATION_WITH_SECURITY_SCOPE = 1 << 11` (0x800) - `NSURL_BOOKMARK_RESOLUTION_WITH_SECURITY_SCOPE = 1 << 10` (0x400) These constants are crucial - using wrong values causes bookmark resolution to fail, which was the root cause of the persistent folder access prompts. ## Error Handling The module includes extensive logging to `security_bookmark_debug.log` for debugging bookmark resolution issues. Key failure points: 1. **Bookmark Creation**: NSOpenPanel URL can't create bookmark 2. **Bookmark Resolution**: Stored bookmark data is invalid/stale 3. **Security Scope Activation**: `startAccessingSecurityScopedResource()` fails 4. **Directory Reading**: Even with security scope, directory is unreadable ## Thread Safety All shared state is protected by Mutex: - `SECURITY_SCOPED_URLS`: Active security-scoped URLs - `SESSION_RESOLVED_URLS`: Session-level bookmark resolution cache ## Converting to Non-Sandboxed (Local Builds Only) If we decide to drop App Store distribution and only support local builds via GitHub releases in the future, we can simplify this module significantly: 1. **Remove Sandbox Entitlements**: Delete `com.apple.security.app-sandbox` and related entitlements from Info.plist 2. **Simplify File Access**: In `file_io.rs`, replace `get_image_paths_macos()` with just `get_image_paths_standard()` - no sandbox means no permission dialogs 3. **Keep Finder Integration**: Keep `handle_opened_file()` and `register_file_handler()` for "Open With" support, but remove all bookmark-related code 4. **Remove Security Bookmark Code**: Delete these functions: - `create_and_store_security_scoped_bookmark()` - `get_resolved_security_scoped_url()` - `restore_directory_access_for_path()` - `request_directory_access_with_optimized_dialog()` - All NSUserDefaults bookmark storage/retrieval 5. **Simplify Data Structures**: Remove `SECURITY_SCOPED_URLS`, `SESSION_RESOLVED_URLS`, and related HashMap management ================================================================================ */ #[cfg(target_os = "macos")] pub mod macos_file_handler { use std::sync::mpsc::Sender; use std::sync::Mutex; use std::collections::HashMap; use objc2::rc::autoreleasepool; use objc2::{msg_send, sel}; use objc2::declare::ClassBuilder; use objc2::runtime::{AnyObject, Sel, AnyClass}; use objc2_app_kit::{NSApplication, NSModalResponse, NSModalResponseOK}; use objc2_foundation::{MainThreadMarker, NSArray, NSString, NSDictionary, NSUserDefaults, NSURL, NSRect, NSPoint, NSSize}; use objc2::rc::Retained; use once_cell::sync::Lazy; use std::io::Write; #[allow(unused_imports)] use log::{debug, info, warn, error}; static mut FILE_CHANNEL: Option> = None; // Store security-scoped URLs globally for session access // FIXED: Store both the URL and whether it has active security scope #[derive(Clone, Debug)] struct SecurityScopedURLInfo { url: Retained, has_active_scope: bool, } static SECURITY_SCOPED_URLS: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); // NEW: Session-level cache for resolved bookmark URLs to implement "resolve once per session" // This prevents multiple URLByResolvingBookmarkData calls for the same directory within a session static SESSION_RESOLVED_URLS: Lazy>>> = Lazy::new(|| Mutex::new(HashMap::new())); // Constants for security-scoped bookmarks const NSURL_BOOKMARK_CREATION_WITH_SECURITY_SCOPE: u64 = 1 << 11; // 0x800 const NSURL_BOOKMARK_RESOLUTION_WITH_SECURITY_SCOPE: u64 = 1 << 10; // 0x400 // ENABLED: Re-enable bookmark restoration after cleanup const DISABLE_BOOKMARK_RESTORATION: bool = false; // ENABLED: Re-enable bookmark creation after implementing safer methods const DISABLE_BOOKMARK_CREATION: bool = false; // ==================== CRASH DEBUG LOGGING ==================== /// Writes a security bookmark debug log entry immediately to disk (not buffered) /// This ensures we can see what happened even if the process crashes immediately after fn write_security_bookmark_debug_log(message: &str) { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); let log_message = format!("{} SECURITY_BOOKMARK: {}", timestamp, message); // Try to write to a security bookmark debug log file in the app's documents directory if let Some(home_dir) = dirs::home_dir() { let log_path = home_dir.join("Documents").join("security_bookmark_debug.log"); if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) .append(true) .open(&log_path) { let _ = writeln!(file, "{}", log_message); let _ = file.flush(); } } // Also print to stderr as backup eprintln!("{}", log_message); } /// Build stable UserDefaults keys for storing bookmarks /// Modern: uses full absolute path to avoid collisions and truncation /// Legacy: previous sanitized/truncated format for backward compatibility fn make_bookmark_keys(directory_path: &str) -> ( Retained, Retained, ) { // Modern key retains full path let modern_key = format!("VSBookmark|{}", directory_path); let modern_ns = NSString::from_str(&modern_key); // Legacy key: first 50 alnum/_ chars let legacy_simple: String = directory_path .chars() .filter(|c| c.is_alphanumeric() || *c == '_') .take(50) .collect(); let legacy_key = format!("VSBookmark_{}", legacy_simple); let legacy_ns = NSString::from_str(&legacy_key); (modern_ns, legacy_ns) } // ==================== END CRASH DEBUG LOGGING ==================== pub fn set_file_channel(sender: Sender) { debug!("Setting file channel for macOS file handler"); unsafe { FILE_CHANNEL = Some(sender); } } /// Stores a security-scoped URL for session access /// FIXED: Store URL info with active scope status fn store_security_scoped_url(path: &str, url: Retained) { store_security_scoped_url_with_info(path, url, false); } /// Stores a security-scoped URL with additional metadata fn store_security_scoped_url_with_info(path: &str, url: Retained, _from_bookmark: bool) { debug!("Storing security-scoped URL for path: {}", path); let info = SecurityScopedURLInfo { url: url.clone(), has_active_scope: true, // Assume it has active scope when stored }; if let Ok(mut urls) = SECURITY_SCOPED_URLS.lock() { urls.insert(path.to_string(), info); debug!("Stored security-scoped URL (total count: {})", urls.len()); } else { error!("Failed to lock security-scoped URLs mutex"); } } /// FIXED: Get the actual resolved path from the security-scoped URL pub fn get_security_scoped_path(original_path: &str) -> Option { if let Ok(urls) = SECURITY_SCOPED_URLS.lock() { if let Some(info) = urls.get(original_path) { if info.has_active_scope { // Get the actual path from the resolved URL autoreleasepool(|pool| unsafe { if let Some(path_nsstring) = info.url.path() { let resolved_path = path_nsstring.as_str(pool); debug!("Resolved security-scoped path: {} -> {}", original_path, resolved_path); Some(resolved_path.to_string()) } else { debug!("No path available from security-scoped URL for: {}", original_path); None } }) } else { debug!("Security-scoped URL exists but scope is not active for: {}", original_path); None } } else { debug!("No security-scoped URL found for: {}", original_path); None } } else { error!("Failed to lock security-scoped URLs mutex"); None } } /// Checks if we have security-scoped access to a path pub fn has_security_scoped_access(path: &str) -> bool { if let Ok(urls) = SECURITY_SCOPED_URLS.lock() { if let Some(info) = urls.get(path) { info.has_active_scope } else { false } } else { false } } /// Gets all accessible paths for debugging pub fn get_accessible_paths() -> Vec { if let Ok(urls) = SECURITY_SCOPED_URLS.lock() { urls.keys().cloned().collect() } else { Vec::new() } } /// Clean up all active security-scoped access (call on app shutdown) /// ADDED: Proper lifecycle management and session cache cleanup pub fn cleanup_all_security_scoped_access() { debug!("Cleaning up all active security-scoped access and session caches"); if let Ok(mut urls) = SECURITY_SCOPED_URLS.lock() { let mut stopped_count = 0; for (path, info) in urls.iter_mut() { if info.has_active_scope { unsafe { let _: () = msg_send![&*info.url, stopAccessingSecurityScopedResource]; info.has_active_scope = false; stopped_count += 1; debug!("Stopped security-scoped access for: {}", path); } } } debug!("Cleaned up {} active security-scoped URLs", stopped_count); } else { error!("Failed to lock security-scoped URLs mutex during cleanup"); } // Clear session cache to ensure fresh resolution on next app launch if let Ok(mut session_cache) = SESSION_RESOLVED_URLS.lock() { let cache_size = session_cache.len(); session_cache.clear(); debug!("Cleared session cache with {} resolved URLs", cache_size); } } /// Creates a security-scoped bookmark from a security-scoped URL and stores it persistently /// FIXED: Simplified and corrected implementation following Apple's documented pattern fn create_and_store_security_scoped_bookmark(url: &Retained, directory_path: &str) -> bool { if DISABLE_BOOKMARK_CREATION { eprintln!("BOOKMARK_CREATE_FIXED: disabled - skipping"); return true; } write_security_bookmark_debug_log(&format!("Creating bookmark for path: {}", directory_path)); debug!("Creating security-scoped bookmark for: {}", directory_path); let result = autoreleasepool(|_pool| unsafe { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: Entered autoreleasepool"); // Validate input path if directory_path.is_empty() || directory_path.len() > 500 { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: ERROR - invalid directory path"); return false; } // Create bookmark data from the security-scoped URL (from NSOpenPanel) let mut error: *mut AnyObject = std::ptr::null_mut(); write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: About to create bookmark data from NSOpenPanel URL"); let bookmark_data: *mut AnyObject = msg_send![ &**url, bookmarkDataWithOptions: NSURL_BOOKMARK_CREATION_WITH_SECURITY_SCOPE includingResourceValuesForKeys: std::ptr::null::() relativeToURL: std::ptr::null::() error: &mut error ]; // Check for errors if !error.is_null() { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: ERROR - bookmark creation failed"); return false; } if bookmark_data.is_null() { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: ERROR - bookmark data is null"); return false; } // Verify it's NSData let nsdata_class = objc2::runtime::AnyClass::get("NSData").unwrap(); let is_nsdata: bool = msg_send![bookmark_data, isKindOfClass: nsdata_class]; if !is_nsdata { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: ERROR - bookmark data is not NSData"); return false; } // DIAGNOSTIC: Check the size and verify bookmark was created with security scope let bookmark_size: usize = msg_send![bookmark_data, length]; write_security_bookmark_debug_log(&format!("BOOKMARK_CREATE_FIXED: Created bookmark size: {} bytes", bookmark_size)); // Test if we can immediately resolve this bookmark to verify it's valid let mut test_is_stale: objc2::runtime::Bool = objc2::runtime::Bool::new(false); let mut test_error: *mut AnyObject = std::ptr::null_mut(); let test_resolved: *mut AnyObject = msg_send![ objc2::runtime::AnyClass::get("NSURL").unwrap(), URLByResolvingBookmarkData: bookmark_data options: NSURL_BOOKMARK_RESOLUTION_WITH_SECURITY_SCOPE relativeToURL: std::ptr::null::() bookmarkDataIsStale: &mut test_is_stale error: &mut test_error ]; if !test_error.is_null() || test_resolved.is_null() { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: ERROR - freshly created bookmark cannot be resolved"); return false; } else { write_security_bookmark_debug_log(&format!("BOOKMARK_CREATE_FIXED: ✅ Bookmark resolves immediately, is_stale={}", test_is_stale.as_bool())); // Test if the resolved URL can activate security scope let test_access: bool = msg_send![test_resolved, startAccessingSecurityScopedResource]; write_security_bookmark_debug_log(&format!("BOOKMARK_CREATE_FIXED: Test startAccessingSecurityScopedResource={}", test_access)); if test_access { let _: () = msg_send![test_resolved, stopAccessingSecurityScopedResource]; write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: ✅ Test security scope works - bookmark is valid"); } else { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: ❌ Test security scope FAILED - bookmark creation problem"); } } write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: Bookmark data created successfully"); // Store in NSUserDefaults with modern key (and legacy for back-compat) let defaults = NSUserDefaults::standardUserDefaults(); let (modern_key, legacy_key) = make_bookmark_keys(directory_path); write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: About to store in NSUserDefaults"); let _: () = msg_send![&*defaults, setObject: bookmark_data forKey: &*modern_key]; // Also store legacy key for back-compat migration let _: () = msg_send![&*defaults, setObject: bookmark_data forKey: &*legacy_key]; // Synchronize to ensure it's persisted let sync_ok: bool = msg_send![&*defaults, synchronize]; if sync_ok { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: SUCCESS - bookmark stored and synchronized"); debug!("Successfully stored security-scoped bookmark"); // Immediate read-back verification and logging let modern_obj: *mut AnyObject = msg_send![&*defaults, objectForKey: &*modern_key]; if modern_obj.is_null() { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: READBACK - modern key not found after store"); } else { let is_data: bool = msg_send![modern_obj, isKindOfClass: nsdata_class]; if is_data { let len: usize = msg_send![modern_obj, length]; write_security_bookmark_debug_log(&format!("BOOKMARK_CREATE_FIXED: READBACK - modern key present, length={} bytes", len)); crate::logging::write_immediate_crash_log(&format!("BOOKMARK_STORE: key='VSBookmark|{}' sync_ok=true len={} bytes", directory_path, len)); } else { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: READBACK - modern key present but not NSData"); crate::logging::write_immediate_crash_log(&format!("BOOKMARK_STORE: key='VSBookmark|{}' sync_ok=true (non-NSData)", directory_path)); } } true } else { write_security_bookmark_debug_log("BOOKMARK_CREATE_FIXED: ERROR - failed to synchronize"); crate::logging::write_immediate_crash_log(&format!("BOOKMARK_STORE: key='VSBookmark|{}' sync_ok=false", directory_path)); false } }); write_security_bookmark_debug_log(&format!("BOOKMARK_CREATE_FIXED: Final result: {}", result)); result } /// Public function to restore directory access for a specific path using stored bookmarks /// This is called when the app needs to regain access to a previously granted directory pub fn restore_directory_access_for_path(directory_path: &str) -> bool { debug!("Restoring directory access for path: {}", directory_path); // Use the new simplified resolution function match get_resolved_security_scoped_url(directory_path) { Some(_url) => { debug!("Successfully restored directory access via bookmark"); true } None => { debug!("Failed to restore directory access via bookmark"); false } } } /// Requests directory access via NSOpenPanel and creates persistent bookmark /// Optimized single-dialog approach that feels like a yes/no confirmation fn request_directory_access_with_optimized_dialog(requested_path: &str) -> bool { crate::logging::write_immediate_crash_log(&format!("OPTIMIZED_DIALOG: Starting for path: {}", requested_path)); debug!("Requesting directory access via optimized dialog for: {}", requested_path); let result = autoreleasepool(|_pool| unsafe { crate::logging::write_immediate_crash_log("OPTIMIZED_DIALOG: Showing single optimized dialog"); let _mtm = MainThreadMarker::new().expect("Must be on main thread"); // Create NSOpenPanel let panel_class = objc2::runtime::AnyClass::get("NSOpenPanel").expect("NSOpenPanel class not found"); let panel: *mut AnyObject = msg_send![panel_class, openPanel]; // Configure panel for directory selection let _: () = msg_send![panel, setCanChooseDirectories: true]; let _: () = msg_send![panel, setCanChooseFiles: false]; let _: () = msg_send![panel, setAllowsMultipleSelection: false]; let _: () = msg_send![panel, setCanCreateDirectories: false]; // CRITICAL: Pre-select the exact directory we want access to crate::logging::write_immediate_crash_log("OPTIMIZED_DIALOG: Pre-selecting the target directory"); let path_nsstring = NSString::from_str(requested_path); let path_url = NSURL::fileURLWithPath(&path_nsstring); let _: () = msg_send![panel, setDirectoryURL: &*path_url]; // Make the dialog look like a permission confirmation let directory_name = std::path::Path::new(requested_path) .file_name() .and_then(|name| name.to_str()) .unwrap_or(requested_path); let title = NSString::from_str("Grant Folder Access"); let _: () = msg_send![panel, setTitle: &*title]; let message = NSString::from_str(&format!( "ViewSkater needs access to the \"{}\" folder to browse your images.\n\n✓ The folder is already selected below\n✓ Click \"Allow Access\" to confirm", directory_name )); let _: () = msg_send![panel, setMessage: &*message]; // Use "Allow Access" instead of generic "Open" let allow_button = NSString::from_str("Allow Access"); let _: () = msg_send![panel, setPrompt: &*allow_button]; // Hide the file browser chrome to make it look more like a simple dialog // Note: These may not all be available, but we'll try to minimize the UI let _: () = msg_send![panel, setCanCreateDirectories: false]; let _: () = msg_send![panel, setShowsHiddenFiles: false]; // Make the window smaller and more focused // Set a smaller, more dialog-like size let window_frame = NSRect { origin: NSPoint { x: 0.0, y: 0.0 }, size: NSSize { width: 500.0, height: 300.0 } }; let _: () = msg_send![panel, setFrame: window_frame display: true]; // Show the panel and get user response crate::logging::write_immediate_crash_log("OPTIMIZED_DIALOG: About to show optimized modal"); let response: NSModalResponse = msg_send![panel, runModal]; crate::logging::write_immediate_crash_log(&format!("OPTIMIZED_DIALOG: Modal completed with response: {:?}", response as i32)); if response == NSModalResponseOK { eprintln!("PANEL_FIXED: User granted access"); debug!("User granted directory access via NSOpenPanel"); // Get the selected URLs array eprintln!("PANEL_FIXED: Getting selected URLs"); let selected_urls: *mut AnyObject = msg_send![panel, URLs]; if selected_urls.is_null() { eprintln!("PANEL_FIXED: ERROR - URLs array is null"); return false; } // Cast to NSArray and get first URL let urls_array = &*(selected_urls as *const NSArray); if urls_array.len() == 0 { eprintln!("PANEL_FIXED: ERROR - URLs array is empty"); return false; } let selected_url = &urls_array[0]; // Get the path string if let Some(path_nsstring) = selected_url.path() { let selected_path = path_nsstring.as_str(_pool); eprintln!("PANEL_FIXED: Selected path: '{}'", selected_path); debug!("Selected directory: {}", selected_path); // Ensure we have active scope before creating the bookmark eprintln!("PANEL_FIXED: Ensuring active scope on selected URL prior to bookmark creation"); let started: bool = msg_send![&*selected_url, startAccessingSecurityScopedResource]; crate::logging::write_immediate_crash_log(&format!("PANEL: startAccessing on selected dir={}", started)); // Convert &NSURL to Retained let _: *mut AnyObject = msg_send![selected_url, retain]; let retained_url = Retained::from_raw(selected_url as *const NSURL as *mut NSURL).unwrap(); // Store the URL for immediate session use store_security_scoped_url(selected_path, retained_url.clone()); eprintln!("PANEL_FIXED: URL stored for session use"); // Create and store persistent bookmark for future sessions eprintln!("PANEL_FIXED: About to create persistent bookmark"); let store_ok = create_and_store_security_scoped_bookmark(&retained_url, selected_path); if started { // Balance the initial startAccessing call for the panel URL let _: () = msg_send![&*selected_url, stopAccessingSecurityScopedResource]; crate::logging::write_immediate_crash_log("PANEL: stopAccessing on selected dir (balanced)"); } if store_ok { eprintln!("PANEL_FIXED: SUCCESS - bookmark created and stored"); debug!("Successfully created persistent bookmark"); true } else { eprintln!("PANEL_FIXED: WARNING - bookmark creation failed, but have session access"); debug!("Failed to create persistent bookmark, but have session access"); true // Still have temporary access for this session } } else { eprintln!("PANEL_FIXED: ERROR - selected URL has no path"); debug!("No path returned from selected URL"); false } } else { eprintln!("PANEL_FIXED: User cancelled"); debug!("User cancelled NSOpenPanel"); false } }); eprintln!("PANEL_FIXED: Final result: {}", result); result } /// Helper function to request parent directory access for a file pub fn request_parent_directory_permission_dialog(file_path: &str) -> bool { debug!("🔍 Requesting parent directory access for file: {}", file_path); if let Some(parent_dir) = std::path::Path::new(file_path).parent() { let parent_dir_str = parent_dir.to_string_lossy(); request_directory_access_with_optimized_dialog(&parent_dir_str) } else { debug!("Could not determine parent directory for: {}", file_path); false } } /// Placeholder for full disk access - in a sandboxed environment, we use directory-specific access pub fn restore_full_disk_access() -> bool { debug!("🔍 restore_full_disk_access() called - deferring to directory-specific restoration"); false // We handle restoration per-directory via restore_directory_access_for_path } /// Check if we have full disk access (simplified check) pub fn has_full_disk_access() -> bool { // Try to read a protected directory if let Some(home_dir) = dirs::home_dir() { let desktop_dir = home_dir.join("Desktop"); match std::fs::read_dir(&desktop_dir) { Ok(_) => { debug!("✅ Full disk access confirmed"); true } Err(_) => { debug!("❌ No full disk access"); false } } } else { false } } /// Handle opening a file via "Open With" from Finder unsafe extern "C" fn handle_opened_file( _this: &mut AnyObject, _sel: Sel, _sender: &AnyObject, files: &NSArray, ) { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Function entry"); write_security_bookmark_debug_log("FINDER_OPEN: handle_opened_file called"); debug!("handle_opened_file called with {} files", files.len()); if files.is_empty() { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Empty files array"); write_security_bookmark_debug_log("FINDER_OPEN: Empty files array received"); debug!("Empty files array received"); return; } crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Processing {} files", files.len())); write_security_bookmark_debug_log(&format!("FINDER_OPEN: Processing {} files", files.len())); autoreleasepool(|pool| { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Entered autoreleasepool"); write_security_bookmark_debug_log("FINDER_OPEN: Entered autoreleasepool"); for (i, file) in files.iter().enumerate() { crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Processing file {} of {}", i + 1, files.len())); write_security_bookmark_debug_log(&format!("FINDER_OPEN: Processing file {} of {}", i + 1, files.len())); let path = file.as_str(pool).to_owned(); crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: File path: {}", path)); debug!("Processing file: {}", path); write_security_bookmark_debug_log(&format!("FINDER_OPEN: File path: {}", path)); crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: About to create NSURL"); write_security_bookmark_debug_log("FINDER_OPEN: About to create NSURL"); // Create NSURL and try to get security-scoped access let url = NSURL::fileURLWithPath(&file); crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: NSURL created"); write_security_bookmark_debug_log("FINDER_OPEN: NSURL created, about to call startAccessingSecurityScopedResource"); let file_accessed: bool = msg_send![&*url, startAccessingSecurityScopedResource]; crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Security access result: {}", file_accessed)); write_security_bookmark_debug_log(&format!("FINDER_OPEN: Security access result: {}", file_accessed)); if file_accessed { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Security access granted"); debug!("Gained security-scoped access to file: {}", path); crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: About to store file URL"); write_security_bookmark_debug_log("FINDER_OPEN: About to store file URL"); // Store the file URL store_security_scoped_url(&path, url.clone()); crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: File URL stored"); write_security_bookmark_debug_log("FINDER_OPEN: File URL stored successfully"); crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: About to get parent directory"); write_security_bookmark_debug_log("FINDER_OPEN: About to get parent directory"); // Try to get parent directory access if let Some(parent_url) = url.URLByDeletingLastPathComponent() { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Got parent URL"); write_security_bookmark_debug_log("FINDER_OPEN: Got parent URL, about to get path"); if let Some(parent_path) = parent_url.path() { let parent_path_str = parent_path.as_str(pool).to_owned(); crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Parent directory: {}", parent_path_str)); debug!("Checking parent directory: {}", parent_path_str); write_security_bookmark_debug_log(&format!("FINDER_OPEN: Parent directory: {}", parent_path_str)); crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: About to test directory access"); write_security_bookmark_debug_log("FINDER_OPEN: About to test directory access"); // Test if we already have directory access match std::fs::read_dir(&parent_path_str) { Ok(_) => { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Have directory access"); debug!("Already have parent directory access"); write_security_bookmark_debug_log("FINDER_OPEN: Have directory access, storing parent URL"); store_security_scoped_url(&parent_path_str, parent_url.clone()); crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Parent URL stored"); write_security_bookmark_debug_log("FINDER_OPEN: Parent URL stored successfully"); } Err(_) => { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: No directory access"); debug!("No parent directory access - will restore from bookmark if available"); write_security_bookmark_debug_log("FINDER_OPEN: No directory access - bookmark restoration needed"); // EARLY RESTORE: attempt to restore and retry crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Attempting early restore_directory_access_for_path on parent [CALLSITE=handle_opened_file]"); if restore_directory_access_for_path(&parent_path_str) { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Early restore succeeded"); debug!("Early bookmark restoration for parent directory succeeded"); if let Some(resolved_parent) = get_security_scoped_path(&parent_path_str) { crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Using resolved parent path: {}", resolved_parent)); match std::fs::read_dir(&resolved_parent) { Ok(_) => { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Directory read successful after early restore"); debug!("Directory access confirmed after early restore"); } Err(e2) => { crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Directory read still failed after early restore: {}", e2)); debug!("Directory read still failed after early restore: {}", e2); } } } else { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: No resolved parent path available after early restore"); } } else { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Early restore failed or no bookmark available"); debug!("Early bookmark restoration failed or no bookmark available for parent directory"); } } } } else { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Parent URL has no path"); write_security_bookmark_debug_log("FINDER_OPEN: Parent URL has no path"); } } else { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Could not get parent URL"); write_security_bookmark_debug_log("FINDER_OPEN: Could not get parent URL"); } crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: About to send file path to main thread"); write_security_bookmark_debug_log("FINDER_OPEN: About to send file path to main thread"); // Send file path to main app if let Some(ref sender) = FILE_CHANNEL { match sender.send(path.clone()) { Ok(_) => { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Successfully sent to main thread"); debug!("Successfully sent file path to main thread"); write_security_bookmark_debug_log("FINDER_OPEN: Successfully sent to main thread"); }, Err(e) => { crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Failed to send: {}", e)); error!("Failed to send file path: {}", e); write_security_bookmark_debug_log(&format!("FINDER_OPEN: Failed to send: {}", e)); }, } } else { crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: FILE_CHANNEL is None"); write_security_bookmark_debug_log("FINDER_OPEN: FILE_CHANNEL is None"); } } else { crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Failed security access for: {}", path)); debug!("Failed to get security-scoped access for file: {}", path); write_security_bookmark_debug_log(&format!("FINDER_OPEN: Failed security access for: {}", path)); } crate::logging::write_immediate_crash_log(&format!("HANDLE_OPENED_FILE: Completed file {} of {}", i + 1, files.len())); write_security_bookmark_debug_log(&format!("FINDER_OPEN: Completed file {} of {}", i + 1, files.len())); } crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: About to exit autoreleasepool"); write_security_bookmark_debug_log("FINDER_OPEN: About to exit autoreleasepool"); }); crate::logging::write_immediate_crash_log("HANDLE_OPENED_FILE: Function completed successfully"); write_security_bookmark_debug_log("FINDER_OPEN: handle_opened_file completed successfully"); } /// Handle opening a single file via legacy "Open With" method (application:openFile:) unsafe extern "C" fn handle_opened_file_single( _this: &mut AnyObject, _sel: Sel, _sender: &AnyObject, filename: &NSString, ) { debug!("handle_opened_file_single called"); autoreleasepool(|pool| { let path = filename.as_str(pool).to_owned(); debug!("Processing single file: {}", path); // Create NSURL and try to get security-scoped access let url = NSURL::fileURLWithPath(&filename); let file_accessed: bool = msg_send![&*url, startAccessingSecurityScopedResource]; if file_accessed { debug!("Gained security-scoped access to single file"); store_security_scoped_url(&path, url); // Send the file path to the main app if let Some(ref sender) = FILE_CHANNEL { match sender.send(path.clone()) { Ok(_) => debug!("Successfully sent single file path to main thread"), Err(e) => error!("Failed to send single file path: {}", e), } } } else { debug!("Failed to get security-scoped access for single file: {}", path); } }); } /// Handle app launch detection to see if we're launched with files unsafe extern "C" fn handle_will_finish_launching( _this: &mut AnyObject, _sel: Sel, _notification: &AnyObject, ) { debug!("App will finish launching"); // Check command line arguments let args: Vec = std::env::args().collect(); debug!("Command line arguments count: {}", args.len()); for (i, arg) in args.iter().enumerate() { if i > 0 && std::path::Path::new(arg).exists() { debug!("Found potential file argument: {}", arg); } } } pub fn register_file_handler() { crate::logging::write_immediate_crash_log("REGISTER_HANDLER: Function entry"); debug!("Registering file handler for macOS"); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to create MainThreadMarker"); let mtm = MainThreadMarker::new().expect("Must be on main thread"); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: MainThreadMarker created"); unsafe { crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to get NSApplication"); let app = NSApplication::sharedApplication(mtm); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: NSApplication obtained"); // Get the existing delegate crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to get delegate"); let delegate = app.delegate().unwrap(); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: Delegate obtained"); // Find out class of the NSApplicationDelegate crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to get delegate class"); let class: &AnyClass = msg_send![&delegate, class]; crate::logging::write_immediate_crash_log("REGISTER_HANDLER: Delegate class obtained"); // Create a subclass of the existing delegate crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to create ClassBuilder"); let mut my_class = ClassBuilder::new("ViewSkaterApplicationDelegate", class).unwrap(); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: ClassBuilder created"); // Add file handling methods crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to add methods"); my_class.add_method( sel!(application:openFiles:), handle_opened_file as unsafe extern "C" fn(_, _, _, _), ); my_class.add_method( sel!(application:openFile:), handle_opened_file_single as unsafe extern "C" fn(_, _, _, _), ); my_class.add_method( sel!(applicationWillFinishLaunching:), handle_will_finish_launching as unsafe extern "C" fn(_, _, _), ); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: Methods added"); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to register class"); let class = my_class.register(); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: Class registered"); // Cast and set the class crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to cast delegate"); let delegate_obj = Retained::cast::(delegate); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to set delegate class"); AnyObject::set_class(&delegate_obj, class); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: Delegate class set"); // Prevent AppKit from interpreting our command line crate::logging::write_immediate_crash_log("REGISTER_HANDLER: About to configure AppKit"); let key = NSString::from_str("NSTreatUnknownArgumentsAsOpen"); let keys = vec![key.as_ref()]; let objects = vec![Retained::cast::(NSString::from_str("NO"))]; let dict = NSDictionary::from_vec(&keys, objects); NSUserDefaults::standardUserDefaults().registerDefaults(dict.as_ref()); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: AppKit configuration completed"); debug!("File handler registration completed"); crate::logging::write_immediate_crash_log("REGISTER_HANDLER: Function completed successfully"); } } /// Get a resolved NSURL instance for direct file operations (not a path string!) /// This implements "resolve once per session" to avoid multiple URLByResolvingBookmarkData calls /// which can fail on macOS when called multiple times for the same bookmark data pub fn get_resolved_security_scoped_url(directory_path: &str) -> Option> { if DISABLE_BOOKMARK_RESTORATION { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: DISABLED - bookmark restoration is disabled"); return None; } crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Starting for path: {}", directory_path)); // STEP 1: Check session cache first - this is the key to "resolve once per session" if let Ok(session_cache) = SESSION_RESOLVED_URLS.lock() { if let Some(cached_url) = session_cache.get(directory_path) { crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: CACHE HIT - Using cached resolved URL for: {}", directory_path)); // Try to activate security scope on the cached URL let access_granted: bool = unsafe { msg_send![&**cached_url, startAccessingSecurityScopedResource] }; crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: startAccessingSecurityScopedResource on cached URL = {}", access_granted)); if access_granted { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: SUCCESS - Using cached URL with active scope"); return Some(cached_url.clone()); } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: CACHE INVALID - Cached URL failed to activate scope, will re-resolve"); // Don't return here - fall through to re-resolve } } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: CACHE MISS - No cached URL found, will resolve fresh"); } } // STEP 2: No valid cached URL found, resolve fresh (this should only happen once per session per directory) crate::logging::write_immediate_crash_log("SESSION_RESOLVE: Resolving bookmark fresh (should only happen once per session)"); let resolved_url = autoreleasepool(|pool| unsafe { let defaults = NSUserDefaults::standardUserDefaults(); let (modern_key, legacy_key) = make_bookmark_keys(directory_path); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Looking for bookmark keys - modern:'{}' legacy:'{}'", modern_key.as_str(pool), legacy_key.as_str(pool))); // Get bookmark data let mut bookmark_data: *mut AnyObject = msg_send![&*defaults, objectForKey: &*modern_key]; let mut used_modern = true; if bookmark_data.is_null() { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: Modern key not found, trying legacy"); bookmark_data = msg_send![&*defaults, objectForKey: &*legacy_key]; used_modern = false; } if bookmark_data.is_null() { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: No bookmark found (neither modern nor legacy)"); return None; } crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Found bookmark using {} key", if used_modern { "modern" } else { "legacy" })); // Verify it's NSData and get size let nsdata_class = objc2::runtime::AnyClass::get("NSData").unwrap(); let is_nsdata: bool = msg_send![bookmark_data, isKindOfClass: nsdata_class]; if !is_nsdata { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: ERROR - bookmark data is not NSData, removing"); let _: () = msg_send![&*defaults, removeObjectForKey: &*modern_key]; let _: () = msg_send![&*defaults, removeObjectForKey: &*legacy_key]; return None; } let bookmark_size: usize = msg_send![bookmark_data, length]; crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Bookmark data is valid NSData, size={} bytes", bookmark_size)); // CRITICAL: Resolve bookmark to get NEW URL instance - MUST use this exact instance let mut is_stale: objc2::runtime::Bool = objc2::runtime::Bool::new(false); let mut error: *mut AnyObject = std::ptr::null_mut(); crate::logging::write_immediate_crash_log("SESSION_RESOLVE: Calling URLByResolvingBookmarkData with security scope (ONCE PER SESSION)"); let resolved_url: *mut AnyObject = msg_send![ objc2::runtime::AnyClass::get("NSURL").unwrap(), URLByResolvingBookmarkData: bookmark_data options: NSURL_BOOKMARK_RESOLUTION_WITH_SECURITY_SCOPE relativeToURL: std::ptr::null::() bookmarkDataIsStale: &mut is_stale error: &mut error ]; // Enhanced error diagnostics if !error.is_null() { // Try to get error description let error_desc: *mut AnyObject = msg_send![error, localizedDescription]; if !error_desc.is_null() { let desc_nsstring = &*(error_desc as *const NSString); let error_msg = desc_nsstring.as_str(pool); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: URLByResolvingBookmarkData ERROR: {}", error_msg)); } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: URLByResolvingBookmarkData failed with unknown error"); } return None; } if resolved_url.is_null() { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: URLByResolvingBookmarkData returned null URL (no error)"); return None; } crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: URLByResolvingBookmarkData succeeded, is_stale={}", is_stale.as_bool())); // Verify it's NSURL let nsurl_class = objc2::runtime::AnyClass::get("NSURL").unwrap(); let is_nsurl: bool = msg_send![resolved_url, isKindOfClass: nsurl_class]; if !is_nsurl { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: ERROR - resolved object is not NSURL"); return None; } // Get resolved path for logging if let Some(path_nsstring) = (&*(resolved_url as *const NSURL)).path() { let resolved_path = path_nsstring.as_str(pool); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Resolved URL path: '{}'", resolved_path)); // Check if path exists and is accessible let path_exists = std::path::Path::new(resolved_path).exists(); let path_is_dir = std::path::Path::new(resolved_path).is_dir(); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Path diagnostics - exists={} is_dir={}", path_exists, path_is_dir)); } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: WARNING - resolved URL has no path"); } // URL property diagnostics let url_is_file_url: bool = msg_send![resolved_url, isFileURL]; let url_has_directory_path: bool = msg_send![resolved_url, hasDirectoryPath]; crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: URL properties - isFileURL={} hasDirectoryPath={}", url_is_file_url, url_has_directory_path)); // CRITICAL: Call startAccessingSecurityScopedResource on the EXACT SAME instance crate::logging::write_immediate_crash_log("SESSION_RESOLVE: About to call startAccessingSecurityScopedResource on resolved URL instance"); // COMPREHENSIVE DIAGNOSTIC: Test multiple approaches to understand what works crate::logging::write_immediate_crash_log("SESSION_RESOLVE: DIAGNOSTIC - Testing multiple security scope approaches"); let mut access_granted = false; let mut diagnostic_info = String::new(); if let Some(path_nsstring) = (&*(resolved_url as *const NSURL)).path() { let resolved_path = path_nsstring.as_str(pool); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Testing access to path: {}", resolved_path)); // TEST 1: Can we read the directory without any security scope calls? let can_read_directly = std::fs::read_dir(resolved_path).is_ok(); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: TEST 1 - Direct read (no security scope): {}", can_read_directly)); diagnostic_info.push_str(&format!("DirectRead={}, ", can_read_directly)); if can_read_directly { access_granted = true; crate::logging::write_immediate_crash_log("SESSION_RESOLVE: ✅ SUCCESS - URL has implicit security scope (no explicit call needed)"); } else { // TEST 2: Try explicit startAccessingSecurityScopedResource crate::logging::write_immediate_crash_log("SESSION_RESOLVE: TEST 2 - Trying explicit startAccessingSecurityScopedResource"); let explicit_access: bool = msg_send![resolved_url, startAccessingSecurityScopedResource]; crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: TEST 2 - startAccessingSecurityScopedResource={}", explicit_access)); diagnostic_info.push_str(&format!("ExplicitStart={}, ", explicit_access)); if explicit_access { // TEST 3: Can we read after explicit call? let can_read_after_explicit = std::fs::read_dir(resolved_path).is_ok(); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: TEST 3 - Read after explicit start: {}", can_read_after_explicit)); diagnostic_info.push_str(&format!("ReadAfterExplicit={}, ", can_read_after_explicit)); if can_read_after_explicit { access_granted = true; crate::logging::write_immediate_crash_log("SESSION_RESOLVE: ✅ SUCCESS - Explicit startAccessingSecurityScopedResource worked"); } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: ❌ FAILURE - Even explicit startAccessingSecurityScopedResource didn't grant access"); } } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: ❌ FAILURE - startAccessingSecurityScopedResource returned false"); } } // Additional diagnostics let path_exists = std::path::Path::new(resolved_path).exists(); let path_is_dir = std::path::Path::new(resolved_path).is_dir(); diagnostic_info.push_str(&format!("PathExists={}, IsDir={}", path_exists, path_is_dir)); } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: ERROR - resolved URL has no path"); diagnostic_info.push_str("NoPath=true"); } crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: DIAGNOSTIC SUMMARY - {}", diagnostic_info)); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: FINAL RESULT - access_granted={}", access_granted)); if access_granted { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: Security scope activated successfully"); // Handle stale bookmarks if is_stale.as_bool() { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: Bookmark is stale, refreshing"); let fresh_bookmark: *mut AnyObject = msg_send![ resolved_url, bookmarkDataWithOptions: NSURL_BOOKMARK_CREATION_WITH_SECURITY_SCOPE includingResourceValuesForKeys: std::ptr::null::() relativeToURL: std::ptr::null::() error: std::ptr::null_mut::<*mut AnyObject>() ]; if !fresh_bookmark.is_null() { let _: () = msg_send![&*defaults, setObject: fresh_bookmark forKey: &*modern_key]; let _: () = msg_send![&*defaults, setObject: fresh_bookmark forKey: &*legacy_key]; let sync_result: bool = msg_send![&*defaults, synchronize]; crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Refreshed stale bookmark, sync_result={}", sync_result)); } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: WARNING - failed to create fresh bookmark for stale data"); } } // Return the resolved URL instance let _: *mut AnyObject = msg_send![resolved_url, retain]; let nsurl_ptr = resolved_url as *mut NSURL; if let Some(retained_url) = Retained::from_raw(nsurl_ptr) { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: SUCCESS - returning active security-scoped URL"); Some(retained_url) } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: ERROR - failed to create Retained"); let _: () = msg_send![resolved_url, stopAccessingSecurityScopedResource]; None } } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: FAILURE - startAccessingSecurityScopedResource returned false"); // Enhanced failure diagnostics // Check macOS version for known issues if let Ok(output) = std::process::Command::new("sw_vers").arg("-productVersion").output() { let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: macOS version: {}", version)); if version.starts_with("15.0") { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: WARNING - macOS 15.0 has known ScopedBookmarksAgent bugs"); } } // Try to create a non-security-scoped bookmark as a test let test_bookmark: *mut AnyObject = msg_send![ resolved_url, bookmarkDataWithOptions: 0u64 // No security scope includingResourceValuesForKeys: std::ptr::null::() relativeToURL: std::ptr::null::() error: std::ptr::null_mut::<*mut AnyObject>() ]; if !test_bookmark.is_null() { let test_size: usize = msg_send![test_bookmark, length]; crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: Non-security-scoped bookmark creation succeeded (size={})", test_size)); crate::logging::write_immediate_crash_log("SESSION_RESOLVE: This suggests the URL is valid but security scope activation failed"); } else { crate::logging::write_immediate_crash_log("SESSION_RESOLVE: Even non-security-scoped bookmark creation failed"); crate::logging::write_immediate_crash_log("SESSION_RESOLVE: This suggests a deeper issue with the resolved URL"); } None } }); // STEP 3: Cache the resolved URL for future use (success or failure) if let Some(ref url) = resolved_url { if let Ok(mut session_cache) = SESSION_RESOLVED_URLS.lock() { session_cache.insert(directory_path.to_string(), url.clone()); crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: CACHED - Stored resolved URL in session cache for: {}", directory_path)); } } else { crate::logging::write_immediate_crash_log(&format!("SESSION_RESOLVE: FAILED - No URL to cache for: {}", directory_path)); } resolved_url } /// Read directory contents using the resolved security-scoped NSURL directly /// This follows Apple's pattern - use the URL instance directly, don't convert to path pub fn read_directory_with_security_scoped_url(directory_path: &str) -> Option> { if let Some(resolved_url) = get_resolved_security_scoped_url(directory_path) { let result = autoreleasepool(|pool| unsafe { // Use NSFileManager directly with the resolved NSURL let file_manager_class = objc2::runtime::AnyClass::get("NSFileManager").unwrap(); let file_manager: *mut AnyObject = msg_send![file_manager_class, defaultManager]; let mut error: *mut AnyObject = std::ptr::null_mut(); let contents: *mut AnyObject = msg_send![ file_manager, contentsOfDirectoryAtURL: &*resolved_url includingPropertiesForKeys: std::ptr::null::() options: 0u64 error: &mut error ]; if !error.is_null() || contents.is_null() { return None; } let nsarray_class = objc2::runtime::AnyClass::get("NSArray").unwrap(); let is_nsarray: bool = msg_send![contents, isKindOfClass: nsarray_class]; if !is_nsarray { return None; } let nsarray = &*(contents as *const NSArray); let mut file_paths = Vec::new(); for i in 0..nsarray.len() { let url = &nsarray[i]; if let Some(path_nsstring) = url.path() { let path_str = path_nsstring.as_str(pool).to_owned(); file_paths.push(path_str); } } Some(file_paths) }); // Clean up - stop accessing the security scoped resource unsafe { let _: () = msg_send![&*resolved_url, stopAccessingSecurityScopedResource]; } result } else { None } } } /// Test function to verify all crash logging methods work /// Call this during startup to confirm logs are being written pub fn test_crash_logging_methods() { crate::logging::write_immediate_crash_log("========== CRASH LOGGING TEST START =========="); crate::logging::write_immediate_crash_log("Testing stderr output"); crate::logging::write_immediate_crash_log("Testing stdout output"); crate::logging::write_immediate_crash_log("Testing syslog output"); crate::logging::write_immediate_crash_log("Testing NSUserDefaults output"); crate::logging::write_immediate_crash_log("Testing file output"); crate::logging::write_immediate_crash_log("========== CRASH LOGGING TEST END =========="); // Test retrieval immediately #[cfg(target_os = "macos")] { let logs = crate::logging::get_crash_debug_logs_from_userdefaults(); println!("Retrieved logs from UserDefaults:"); for log in logs { println!(" {}", log); } } } // ==================== END CRASH DEBUG LOGGING ==================== ================================================ FILE: src/main.rs ================================================ #![windows_subsystem = "windows"] mod cache; mod navigation_keyboard; mod navigation_slider; mod file_io; mod menu; mod widgets; mod pane; mod ui; mod loading_status; mod loading_handler; mod config; mod settings; mod app; mod utils; mod build_info; mod logging; #[cfg(feature = "selection")] mod selection_manager; #[cfg(feature = "coco")] mod coco; mod settings_modal; mod replay; mod exif_utils; mod window_state; #[cfg(target_os = "macos")] mod macos_file_access; mod archive_cache; use iced_winit::winit::dpi::PhysicalPosition; #[allow(unused_imports)] use log::{Level, trace, debug, info, warn, error}; use std::task::Wake; use std::task::Waker; use std::sync::Arc; use std::borrow::Cow; use std::time::Instant; use std::sync::Mutex; use std::time::Duration; use once_cell::sync::Lazy; use std::sync::atomic::{AtomicUsize, Ordering}; use std::collections::VecDeque; use winit::{ event::WindowEvent, event_loop::{ControlFlow, EventLoop}, keyboard::ModifiersState, }; #[cfg(target_os = "linux")] use winit::platform::x11::WindowAttributesExtX11; use iced_wgpu::graphics::Viewport; use iced_wgpu::{wgpu, Engine, Renderer}; use iced_winit::{conversion, Proxy}; use iced_winit::core::mouse; use iced_winit::core::renderer; use iced_winit::core::{Color, Font, Pixels, Size, Theme}; use iced_winit::futures; use iced_winit::runtime::program; use iced_winit::runtime::Debug; use iced_winit::winit; use iced_winit::winit::event::ElementState; use iced_winit::Clipboard; use iced_runtime::Action; use iced_runtime::task::into_stream; use iced_winit::winit::event_loop::EventLoopProxy; use iced_wgpu::graphics::text::font_system; use iced_winit::futures::futures::task; use iced_winit::core::window; use iced_futures::futures::channel::oneshot; use iced_wgpu::engine::CompressionStrategy; use crate::settings::WindowState; use crate::utils::timing::TimingStats; use crate::app::{Message, DataViewer}; use crate::widgets::shader::scene::Scene; use crate::config::CONFIG; use std::sync::mpsc::{self as std_mpsc, Receiver as StdReceiver, Sender as StdSender}; // Maximum texture size supported by most GPUs (prevents wgpu surface configuration panic) const MAX_TEXTURE_SIZE: u32 = 8192; use iced_wgpu::{get_image_rendering_diagnostics, log_image_rendering_stats}; use iced_wgpu::engine::ImageConfig; use std::sync::mpsc::{self, Receiver}; static ICON: &[u8] = include_bytes!("../assets/icon_48.png"); pub static FRAME_TIMES: Lazy>> = Lazy::new(|| { Mutex::new(Vec::with_capacity(120)) }); static CURRENT_FPS: Lazy> = Lazy::new(|| { Mutex::new(0.0) }); static _STATE_UPDATE_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("State Update")) }); static _WINDOW_EVENT_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("Window Event")) }); static CURRENT_MEMORY_USAGE: Lazy> = Lazy::new(|| { Mutex::new(0) }); static LAST_MEMORY_UPDATE: Lazy> = Lazy::new(|| { Mutex::new(Instant::now()) }); static LAST_STATS_UPDATE: Lazy> = Lazy::new(|| { Mutex::new(Instant::now()) }); static LAST_RENDER_TIME: Lazy> = Lazy::new(|| { Mutex::new(Instant::now()) }); static LAST_ASYNC_DELIVERY_TIME: Lazy> = Lazy::new(|| { Mutex::new(Instant::now()) }); static LAST_QUEUE_LENGTH: AtomicUsize = AtomicUsize::new(0); const QUEUE_LOG_THRESHOLD: usize = 20; const QUEUE_RESET_THRESHOLD: usize = 50; // Fullscreen UI detection zones #[cfg(any(target_os = "macos", target_os = "windows"))] const FULLSCREEN_TOP_ZONE_HEIGHT: f64 = 200.0; // Larger zone for menu interactions in fullscreen mode #[cfg(not(any(target_os = "macos", target_os = "windows")))] const FULLSCREEN_TOP_ZONE_HEIGHT: f64 = 50.0; // Standard zone for other platforms const FULLSCREEN_BOTTOM_ZONE_HEIGHT: f64 = 100.0; // Standard bottom zone for all platforms // Store the actual shared log buffer from the file_io module #[allow(clippy::type_complexity)] static SHARED_LOG_BUFFER: Lazy>>>>>> = Lazy::new(|| { Arc::new(Mutex::new(None)) }); // Store the stdout buffer for global access #[allow(clippy::type_complexity)] static SHARED_STDOUT_BUFFER: Lazy>>>>>> = Lazy::new(|| { Arc::new(Mutex::new(None)) }); pub fn get_shared_log_buffer() -> Option>>> { SHARED_LOG_BUFFER.lock().unwrap().clone() } pub fn set_shared_log_buffer(buffer: Arc>>) { *SHARED_LOG_BUFFER.lock().unwrap() = Some(buffer); } pub fn get_shared_stdout_buffer() -> Option>>> { SHARED_STDOUT_BUFFER.lock().unwrap().clone() } pub fn set_shared_stdout_buffer(buffer: Arc>>) { *SHARED_STDOUT_BUFFER.lock().unwrap() = Some(buffer); } fn load_icon() -> Option { let image = image::load_from_memory(ICON).ok()?.into_rgba8(); let (width, height) = image.dimensions(); winit::window::Icon::from_rgba(image.into_raw(), width, height).ok() } use clap::Parser; use std::path::PathBuf; #[derive(Parser)] #[command(name = "viewskater")] #[command(about = "A fast image viewer for browsing large collections of images")] #[command(version)] struct Args { /// Path to image file or directory to open path: Option, /// Path to custom settings file #[arg(long = "settings")] settings_path: Option, /// Enable replay/benchmark mode #[arg(long)] replay: bool, /// Test directories for replay mode (can be specified multiple times) #[arg(long = "test-dir", value_name = "DIR")] test_directories: Vec, /// Duration to test each directory in seconds #[arg(long, default_value = "10")] duration: u64, /// Navigation interval in milliseconds #[arg(long, default_value = "50")] nav_interval: u64, /// Test directions: right, left, both #[arg(long, default_value = "both")] directions: String, /// Output file for benchmark results #[arg(long, value_name = "FILE")] output: Option, /// Output format: text, json, markdown #[arg(long, default_value = "text")] output_format: String, /// Number of complete iterations/cycles to run #[arg(long, default_value = "1")] iterations: u32, /// Verbose output during replay #[arg(long)] verbose: bool, /// Exit automatically after replay completes #[arg(long)] auto_exit: bool, /// Skip first N images for metrics (to exclude pre-cached images with inflated FPS) #[arg(long, default_value = "0")] skip_initial: usize, /// Navigation mode: keyboard (continuous skating) or slider (stepped position changes) #[arg(long, default_value = "keyboard")] nav_mode: String, /// Step size for slider navigation mode (how many images to skip per navigation) #[arg(long, default_value = "1")] slider_step: u16, } fn register_font_manually(font_data: &'static [u8]) { use std::sync::RwLockWriteGuard; // Get a mutable reference to the font system let font_system = font_system(); let mut font_system_guard: RwLockWriteGuard<_> = font_system .write() .expect("Failed to acquire font system lock"); // Load the font into the global font system font_system_guard.load_font(Cow::Borrowed(font_data)); } #[allow(dead_code)] enum Control { ChangeFlow(winit::event_loop::ControlFlow), CreateWindow { id: window::Id, settings: window::Settings, title: String, monitor: Option, on_open: oneshot::Sender<()>, }, Exit, } #[allow(dead_code)] enum Event { EventLoopAwakened(winit::event::Event), WindowCreated { id: window::Id, window: winit::window::Window, exit_on_close_request: bool, make_visible: bool, on_open: oneshot::Sender<()>, }, } fn monitor_message_queue(state: &mut program::State) { // Check queue length let queue_len = state.queued_messages_len(); LAST_QUEUE_LENGTH.store(queue_len, Ordering::SeqCst); trace!("Message queue size: {}", queue_len); // Log if the queue is getting large if queue_len > QUEUE_LOG_THRESHOLD { debug!("Message queue size: {}", queue_len); } // Reset queue if it exceeds our threshold if queue_len > QUEUE_RESET_THRESHOLD { warn!("MESSAGE QUEUE OVERLOAD: {} messages pending - clearing queue", queue_len); state.clear_queued_messages(); } } // Define a message type for renderer configuration requests enum RendererRequest { UpdateCompressionStrategy(CompressionStrategy), ClearPrimitiveStorage, // Add other renderer configuration requests here if needed } fn update_memory_usage() { // Just delegate to the utils::mem implementation utils::mem::update_memory_usage(); } pub fn main() -> Result<(), winit::error::EventLoopError> { // CRITICAL: Write to crash log IMMEDIATELY - before any other operations crate::logging::write_crash_debug_log("MAIN: App startup initiated"); // Set up signal handler FIRST to catch low-level crashes crate::logging::write_crash_debug_log("MAIN: About to setup signal handler"); crate::logging::setup_signal_crash_handler(); crate::logging::write_crash_debug_log("MAIN: Signal handler setup completed"); // Set up stdout capture FIRST, before any println! statements crate::logging::write_crash_debug_log("MAIN: About to setup stdout capture"); let shared_stdout_buffer = crate::logging::setup_stdout_capture(); set_shared_stdout_buffer(Arc::clone(&shared_stdout_buffer)); crate::logging::write_crash_debug_log("MAIN: Stdout capture setup completed"); println!("ViewSkater starting..."); crate::logging::write_crash_debug_log("MAIN: ViewSkater starting message printed"); // Set up panic hook to log to a file crate::logging::write_crash_debug_log("MAIN: About to setup logger"); let app_name = "viewskater"; let shared_log_buffer = crate::logging::setup_logger(app_name); // Store the log buffer reference for global access set_shared_log_buffer(Arc::clone(&shared_log_buffer)); crate::logging::write_crash_debug_log("MAIN: Logger setup completed"); crate::logging::write_crash_debug_log("MAIN: About to setup panic hook"); crate::logging::setup_panic_hook(app_name, shared_log_buffer); crate::logging::write_crash_debug_log("MAIN: Panic hook setup completed"); // Initialize winit FIRST let event_loop = EventLoop::>::with_user_event() .build() .unwrap(); // Set up the file channel AFTER winit initialization let (file_sender, file_receiver) = mpsc::channel(); // Parse command line arguments let args = Args::parse(); let settings_path = args.settings_path.clone(); #[cfg(not(target_os = "macos"))] let file_arg = args.path.as_ref().map(|p| p.to_string_lossy().to_string()); // Create replay configuration if replay mode is enabled let replay_config = if args.replay { let test_dirs = if args.test_directories.is_empty() { // If no test directories specified, try to use the path argument if let Some(ref path) = args.path { vec![path.clone()] } else { eprintln!("Error: Replay mode requires at least one test directory. Use --test-dir or provide a path argument."); std::process::exit(1); } } else { args.test_directories.clone() }; // Validate that all test directories exist for dir in &test_dirs { if !dir.exists() { eprintln!("Error: Test directory does not exist: {}", dir.display()); std::process::exit(1); } } // Parse directions let directions = match args.directions.to_lowercase().as_str() { "right" => vec![replay::ReplayDirection::Right], "left" => vec![replay::ReplayDirection::Left], "both" => vec![replay::ReplayDirection::Both], _ => { eprintln!("Error: Invalid direction '{}'. Use 'right', 'left', or 'both'", args.directions); std::process::exit(1); } }; // Parse navigation mode let navigation_mode = match args.nav_mode.to_lowercase().as_str() { "slider" => replay::NavigationMode::Slider, _ => replay::NavigationMode::Keyboard, }; println!("Replay mode enabled:"); println!(" Test directories: {:?}", test_dirs); println!(" Duration per directory: {}s", args.duration); println!(" Navigation interval: {}ms", args.nav_interval); println!(" Directions: {:?}", directions); println!(" Navigation mode: {:?}", navigation_mode); if navigation_mode == replay::NavigationMode::Slider { println!(" Slider step: {}", args.slider_step); } println!(" Iterations: {}", args.iterations); if let Some(ref output) = args.output { println!(" Output file: {}", output.display()); } let output_format = match args.output_format.to_lowercase().as_str() { "json" => replay::OutputFormat::Json, "markdown" | "md" => replay::OutputFormat::Markdown, _ => replay::OutputFormat::Text, }; Some(replay::ReplayConfig { test_directories: test_dirs, duration_per_directory: Duration::from_secs(args.duration), navigation_interval: Duration::from_millis(args.nav_interval), directions, output_file: args.output.clone(), output_format, verbose: args.verbose, iterations: args.iterations, auto_exit: args.auto_exit, skip_initial_images: args.skip_initial, navigation_mode, slider_step: args.slider_step, }) } else { None }; // Test crash debug logging immediately at startup crate::logging::write_crash_debug_log("========== VIEWSKATER STARTUP =========="); crate::logging::write_crash_debug_log("Testing crash debug logging system at startup"); crate::logging::write_crash_debug_log(&format!("App version: {}", env!("CARGO_PKG_VERSION"))); crate::logging::write_crash_debug_log(&format!("Timestamp: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"))); crate::logging::write_crash_debug_log("If you can see this message, crash debug logging is working"); crate::logging::write_crash_debug_log("========================================="); // Test all logging methods comprehensively #[cfg(target_os = "macos")] macos_file_access::test_crash_logging_methods(); // Register file handler BEFORE creating the runner // This is required on macOS so the app can receive file paths // when launched by opening a file (e.g. double-clicking in Finder) // or using "Open With". Must be set up early in app lifecycle. #[cfg(target_os = "macos")] { crate::logging::write_crash_debug_log("MAIN: About to set file channel"); macos_file_access::macos_file_handler::set_file_channel(file_sender); crate::logging::write_crash_debug_log("MAIN: File channel set"); // NOTE: Automatic bookmark cleanup is DISABLED in production builds to avoid // wiping valid stored access. Use a special maintenance build or developer // tooling to invoke cleanup if ever needed. crate::logging::write_crash_debug_log("MAIN: About to register file handler"); macos_file_access::macos_file_handler::register_file_handler(); crate::logging::write_crash_debug_log("MAIN: File handler registered"); // Try to restore full disk access from previous session crate::logging::write_crash_debug_log("MAIN: About to restore full disk access"); debug!("🔍 Attempting to restore full disk access on startup"); let restore_result = macos_file_access::macos_file_handler::restore_full_disk_access(); debug!("🔍 Restore full disk access result: {}", restore_result); crate::logging::write_crash_debug_log(&format!("MAIN: Restore full disk access result: {}", restore_result)); println!("macOS file handler registered"); crate::logging::write_crash_debug_log("MAIN: macOS file handler registration completed"); } // Handle command line arguments for Linux (and Windows) // This supports double-click and "Open With" functionality via .desktop files on Linux #[cfg(not(target_os = "macos"))] { if let Some(ref file_path) = file_arg { println!("File path from command line: {}", file_path); // Validate that the path exists and is a file or directory if std::path::Path::new(file_path).exists() { if let Err(e) = file_sender.send(file_path.clone()) { println!("Failed to send file path through channel: {}", e); } else { println!("Successfully queued file path for loading: {}", file_path); } } else { println!("Warning: Specified file path does not exist: {}", file_path); } } } // Rest of the initialization... let proxy: EventLoopProxy> = event_loop.create_proxy(); // Create channels for event and control communication let (event_sender, _event_receiver) = std_mpsc::channel(); let (_control_sender, control_receiver) = std_mpsc::channel(); #[allow(clippy::large_enum_variant)] enum Runner { Loading { proxy: EventLoopProxy>, event_sender: StdSender>>, control_receiver: StdReceiver, file_receiver: Receiver, settings_path: Option, replay_config: Option, }, Ready { window: Arc, device: Arc, queue: Arc, surface: wgpu::Surface<'static>, format: wgpu::TextureFormat, present_mode: wgpu::PresentMode, engine: Arc>, renderer: std::rc::Rc>, state: program::State, cursor_position: Option>, clipboard: Clipboard, runtime: iced_futures::Runtime< iced_futures::backend::native::tokio::Executor, Proxy, Action, >, viewport: Viewport, modifiers: ModifiersState, resized: bool, moved: bool, // Flag to track window movement redraw: bool, last_title: String, // Track last set title to avoid unnecessary updates debug: bool, debug_tool: Debug, _event_sender: StdSender>>, control_receiver: StdReceiver, _context: task::Context<'static>, custom_theme: Theme, renderer_request_receiver: Receiver, }, } impl Runner { fn process_event( &mut self, event_loop: &winit::event_loop::ActiveEventLoop, event: Event>, ) { if event_loop.exiting() { return; } match self { Runner::Loading { .. } => { // Handle events while loading match event { Event::EventLoopAwakened(winit::event::Event::NewEvents(_)) => { // Continue loading } Event::EventLoopAwakened(winit::event::Event::AboutToWait) => { // Continue loading } _ => {} } } Runner::Ready { window, device, queue, surface, format, present_mode, engine, renderer, state, viewport, cursor_position, modifiers, clipboard, runtime, resized, moved, redraw, last_title, debug, debug_tool, control_receiver, custom_theme, renderer_request_receiver, .. } => { // Handle events in ready state match event { Event::EventLoopAwakened(winit::event::Event::WindowEvent { window_id: _window_id, event: window_event, }) => { let _window_event_start = Instant::now(); // Monitor the message queue and clear it if it's getting large monitor_message_queue(state); match window_event { WindowEvent::Focused(true) => { event_loop.set_control_flow(ControlFlow::Poll); *moved = false; } WindowEvent::Focused(false) => { event_loop.set_control_flow(ControlFlow::Wait); window_state::save_window_state_to_disk(state.program(), &window); } WindowEvent::Resized(size) => { if size.width > 0 && size.height > 0 { *resized = true; // Update app's window width for responsive layout // Divide by scale factor to get logical pixels (important for macOS Retina) let logical_width = size.width as f32 / window.scale_factor() as f32; state.queue_message(Message::WindowResized(logical_width, size, window.is_maximized())); } else { // Skip resizing and avoid configuring the surface *resized = false; } } WindowEvent::Moved(position) => { state.queue_message(Message::PositionChanged(position, window.current_monitor())); *moved = true; } WindowEvent::CloseRequested => { window_state::save_window_state_to_disk(state.program(), &window); #[cfg(target_os = "macos")] { // Clean up all active security-scoped access before shutdown macos_file_access::macos_file_handler::cleanup_all_security_scoped_access(); } event_loop.exit(); } WindowEvent::CursorMoved { position, .. } => { if state.program().window_state == WindowState::FullScreen { state.queue_message(Message::CursorOnTop(position.y < FULLSCREEN_TOP_ZONE_HEIGHT)); state.queue_message(Message::CursorOnFooter( position.y > (window.inner_size().height as f64 - FULLSCREEN_BOTTOM_ZONE_HEIGHT))); } *cursor_position = Some(position); } WindowEvent::MouseInput { state, .. } => { if state == ElementState::Released { *moved = false; // Reset flag when mouse is released } } WindowEvent::ModifiersChanged(new_modifiers) => { *modifiers = new_modifiers.state(); } WindowEvent::KeyboardInput { event: winit::event::KeyEvent { physical_key: winit::keyboard::PhysicalKey::Code( winit::keyboard::KeyCode::F11), state: ElementState::Pressed, repeat: false, .. }, .. } => { #[cfg(target_os = "macos")] { // On macOS, window.fullscreen().is_some() doesn't work with set_simple_fullscreen() // so we need to use the application's internal state let fullscreen = if state.program().window_state == WindowState::FullScreen { state.queue_message(Message::ToggleFullScreen(false)); None } else { state.queue_message(Message::ToggleFullScreen(true)); Some(winit::window::Fullscreen::Borderless(None)) }; use iced_winit::winit::platform::macos::WindowExtMacOS; window.set_simple_fullscreen(fullscreen.is_some()); } #[cfg(not(target_os = "macos"))] { let fullscreen = if window.fullscreen().is_some() { state.queue_message(Message::ToggleFullScreen(false)); None } else { state.queue_message(Message::ToggleFullScreen(true)); Some(winit::window::Fullscreen::Borderless(None)) }; window.set_fullscreen(fullscreen); } } WindowEvent::KeyboardInput { event: winit::event::KeyEvent { physical_key: winit::keyboard::PhysicalKey::Code( winit::keyboard::KeyCode::Escape), state: ElementState::Pressed, repeat: false, .. }, .. } => { // Handle Escape key to exit fullscreen on macOS #[cfg(target_os = "macos")] { if window.fullscreen().is_some() || state.program().window_state == WindowState::FullScreen { state.queue_message(Message::ToggleFullScreen(false)); use iced_winit::winit::platform::macos::WindowExtMacOS; window.set_simple_fullscreen(false); } } #[cfg(not(target_os = "macos"))] { if window.fullscreen().is_some() { state.queue_message(Message::ToggleFullScreen(false)); window.set_fullscreen(None); } } } _ => {} } *redraw = true; // Map window event to iced event and queue it. // Events are batched and processed in a single state.update() // call before rendering, rather than per-event. This prevents // 60+ individual view()/layout() rebuilds between renders when // mouse events pile up during a 4K image decode (60ms render). // Without batching, UserEvents (async slider image loads) are // delayed behind the wall of per-event WindowEvent processing. if let Some(event) = iced_winit::conversion::window_event( window_event, window.scale_factor(), *modifiers, ) { state.queue_message(Message::Event(event.clone())); state.queue_event(event); *redraw = true; } // Handle resizing if *resized { let size = window.inner_size(); // Cap to wgpu texture limits to prevent panic let capped_width = size.width.min(MAX_TEXTURE_SIZE); let capped_height = size.height.min(MAX_TEXTURE_SIZE); *viewport = Viewport::with_physical_size( Size::new(capped_width, capped_height), window.scale_factor(), ); surface.configure( device, &wgpu::SurfaceConfiguration { format: *format, usage: wgpu::TextureUsages::RENDER_ATTACHMENT, width: capped_width, height: capped_height, present_mode: *present_mode, alpha_mode: wgpu::CompositeAlphaMode::Auto, view_formats: vec![], desired_maximum_frame_latency: 2, }, ); *resized = false; } // Process any pending renderer requests to update iced_wgpu's config // NOTE1: we need to use a while loop to process all pending requests // because try_recv() only returns one request at a time // NOTE2: we need to do this sender/receiver pattern to avoid deadlocks while let Ok(request) = renderer_request_receiver.try_recv() { match request { RendererRequest::UpdateCompressionStrategy(strategy) => { debug!("Main thread handling compression strategy update to {:?}", strategy); let config = ImageConfig { atlas_size: CONFIG.atlas_size, compression_strategy: strategy, }; // We already have locks for renderer and engine in the rendering code let mut engine_guard = engine.lock().unwrap(); let mut renderer_guard = renderer.lock().unwrap(); // Update the config safely from the main render thread renderer_guard.update_image_config(device, &mut engine_guard, config); debug!("Compression strategy updated successfully in main thread"); } RendererRequest::ClearPrimitiveStorage => { debug!("Main thread handling primitive storage clear request"); // Get engine lock let mut engine_guard = engine.lock().unwrap(); // Access the primitive storage directly engine_guard.clear_primitive_storage(); debug!("Primitive storage cleared successfully"); }, } } // Process events/messages, or refresh spinner animation during loading. // The spinner widget computes its angle from Instant::now() in draw(), // so state.update() must run each frame to call view()/draw() and // produce updated render output. if !state.is_queue_empty() || state.program().is_any_pane_loading() { // We update iced let (_, task) = state.update( viewport.logical_size(), cursor_position .map(|p| { conversion::cursor_position( p, viewport.scale_factor(), ) }) .map(mouse::Cursor::Available) .unwrap_or(mouse::Cursor::Unavailable), &mut *renderer.lock().unwrap(), custom_theme, &renderer::Style { text_color: Color::WHITE, }, clipboard, debug_tool, ); let _ = 'runtime_call: { let Some(t) = task else { break 'runtime_call 1; }; let Some(stream) = into_stream(t) else { break 'runtime_call 1; }; runtime.run(stream); 0 }; } // Render if needed if *redraw { *redraw = false; let frame_start = Instant::now(); // Set window title when the title is actually changed let new_title = state.program().title(); if new_title != *last_title { window.set_title(&new_title); *last_title = new_title; } match surface.get_current_texture() { Ok(frame) => { let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Render Encoder"), }); let present_start = Instant::now(); { let mut engine_guard = engine.lock().unwrap(); let mut renderer_guard = renderer.lock().unwrap(); renderer_guard.present( &mut engine_guard, device, queue, &mut encoder, None, frame.texture.format(), &view, viewport, &debug_tool.overlay(), ); // Submit commands while still holding the lock engine_guard.submit(queue, encoder); } let present_time = present_start.elapsed(); // Submit the commands to the queue let submit_start = Instant::now(); let submit_time = submit_start.elapsed(); let present_frame_start = Instant::now(); frame.present(); let present_frame_time = present_frame_start.elapsed(); // Add tracking here to monitor the render cycle if *debug { track_render_cycle(); } // Always log these if they're abnormally long if present_time.as_millis() > 50 { warn!("BOTTLENECK: Renderer present took {:?}", present_time); } if submit_time.as_millis() > 50 { warn!("BOTTLENECK: Command submission took {:?}", submit_time); } if present_frame_time.as_millis() > 50 { warn!("BOTTLENECK: Frame presentation took {:?}", present_frame_time); } // Original debug logging if *debug { trace!("Renderer present took {:?}", present_time); trace!("Command submission took {:?}", submit_time); trace!("Frame presentation took {:?}", present_frame_time); } // Update the mouse cursor window.set_cursor( iced_winit::conversion::mouse_interaction( state.mouse_interaction(), ), ); // Only track menu cursor in fullscreen, and only when value changes if state.program().window_state == WindowState::FullScreen { let new_val = !state.program().cursor_on_footer && state.mouse_interaction() == mouse::Interaction::Pointer; if new_val != state.program().cursor_on_menu { state.queue_message(Message::CursorOnMenu(new_val)); } } // Continue animation loop if spinner is active if state.program().is_any_pane_loading() { window.request_redraw(); } if *debug { let total_frame_time = frame_start.elapsed(); trace!("Total frame time: {:?}", total_frame_time); } } Err(error) => match error { wgpu::SurfaceError::OutOfMemory => { panic!("Swapchain error: {error}. Rendering cannot continue."); } _ => { // Retry rendering on the next frame window.request_redraw(); } }, } // Record frame time and update memory usage if let Ok(mut frame_times) = FRAME_TIMES.lock() { let now = Instant::now(); frame_times.push(now); // Only update stats once per second let should_update_stats = { if let Ok(last_update) = LAST_STATS_UPDATE.lock() { last_update.elapsed().as_secs() >= 1 } else { false } }; if should_update_stats { // Update the timestamp if let Ok(mut last_update) = LAST_STATS_UPDATE.lock() { *last_update = now; } // Clean up old frames let cutoff = now - Duration::from_secs(1); frame_times.retain(|&t| t > cutoff); // Calculate FPS let fps = frame_times.len() as f32; trace!("Current FPS: {:.1}", fps); // Store the current FPS value if let Ok(mut current_fps) = CURRENT_FPS.lock() { *current_fps = fps; } // Update memory usage (which has its own throttling as a backup) update_memory_usage(); } } } // Record window event time //let window_event_time = window_event_start.elapsed(); //WINDOW_EVENT_STATS.lock().unwrap().add_measurement(window_event_time); // Introduce a short sleep to yield control to the OS and improve responsiveness. // This prevents the event loop from monopolizing the CPU, preventing lags. // A small delay (300µs) seems to be enough to avoid lag while maintaining high performance. std::thread::sleep(std::time::Duration::from_micros(300)); } Event::EventLoopAwakened(winit::event::Event::UserEvent(action)) => { match action { Action::Widget(w) => { state.operate( &mut *renderer.lock().unwrap(), std::iter::once(w), Size::new(viewport.physical_size().width as f32, viewport.physical_size().height as f32), debug_tool, ); } Action::Clipboard(action) => { match action { iced_runtime::clipboard::Action::Write { target, contents } => { debug!("Main thread received clipboard write request: {:?}, {:?}", target, contents); // Write to the clipboard using the Clipboard instance clipboard.write(target, contents); debug!("Successfully wrote to clipboard"); } iced_runtime::clipboard::Action::Read { target, channel } => { debug!("Main thread received clipboard read request: {:?}", target); // Read from clipboard and send result back through the channel let content = clipboard.read(target); if let Err(err) = channel.send(content) { error!("Failed to send clipboard content through channel: {:?}", err); } } } } Action::Output(message) => { state.queue_message(message); } _ => {} } *redraw = true; } Event::EventLoopAwakened(winit::event::Event::AboutToWait) => { // Process any pending control messages while let Ok(control) = control_receiver.try_recv() { match control { Control::ChangeFlow(flow) => { use winit::event_loop::ControlFlow; match (event_loop.control_flow(), flow) { ( ControlFlow::WaitUntil(current), ControlFlow::WaitUntil(new), ) if new < current => {} ( ControlFlow::WaitUntil(target), ControlFlow::Wait, ) if target > Instant::now() => {} _ => { event_loop.set_control_flow(flow); } } } Control::Exit => { window_state::save_window_state_to_disk(state.program(), &window); #[cfg(target_os = "macos")] { // Clean up all active security-scoped access before shutdown macos_file_access::macos_file_handler::cleanup_all_security_scoped_access(); } event_loop.exit(); } _ => {} } } // Request a redraw if needed if *redraw { window.request_redraw(); } } _ => {} } } } } } impl winit::application::ApplicationHandler> for Runner { fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) { match self { Self::Loading { proxy, event_sender, control_receiver, file_receiver, settings_path, replay_config } => { info!("resumed()..."); let custom_theme = Theme::custom_with_fn( "Custom Theme".to_string(), iced_winit::core::theme::Palette { primary: iced_winit::core::Color::from_rgba8(20, 148, 163, 1.0), text: iced_winit::core::Color::from_rgba8(224, 224, 224, 1.0), ..Theme::Dark.palette() }, |palette| { // Generate the extended palette from the base palette let mut extended: iced_core::theme::palette::Extended = iced_core::theme::palette::Extended::generate(palette); // Customize specific parts of the extended palette extended.primary.weak.text = iced_winit::core::Color::from_rgba8(224, 224, 224, 1.0); // Return the modified extended palette extended } ); // On macOS, NSWindow.zoom() handles maximize instead of winit's set_maximized #[cfg(target_os = "macos")] let should_maximize = false; #[cfg(not(target_os = "macos"))] let should_maximize = CONFIG.window_state == WindowState::Maximized; // Cap window size to wgpu texture limits to prevent surface configuration panic let capped_width = CONFIG.window_width.min(MAX_TEXTURE_SIZE); let capped_height = CONFIG.window_height.min(MAX_TEXTURE_SIZE); // Platform-specific window creation: // Platform-specific window positioning: // - X11: with_position() works, set_outer_position() doesn't // - macOS: set_outer_position() works, with_position() causes issues // - Windows: set_outer_position() works #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] let mut window_attrs = winit::window::WindowAttributes::default() .with_inner_size(winit::dpi::PhysicalSize::new( capped_width, capped_height )) .with_maximized(should_maximize) .with_title("ViewSkater") .with_resizable(true); let config_position = PhysicalPosition::new(CONFIG.window_position_x, CONFIG.window_position_y); // Only use with_position on Linux (X11 needs it) // with_name sets WM_CLASS (X11) / app_id (Wayland) for .desktop file matching #[cfg(target_os = "linux")] { window_attrs = window_attrs .with_maximized(CONFIG.window_state == WindowState::Maximized) .with_position(config_position) .with_name("viewskater", "viewskater"); } let window = Arc::new( event_loop .create_window(window_attrs) .expect("Create window"), ); // Set position after creation for macOS/Windows #[cfg(not(target_os = "linux"))] { window.set_outer_position(config_position); // Prevents window appearing outside of monitor #[cfg(target_os = "windows")] { let tuple = window_state::get_window_visible(config_position, window.outer_size(), window.current_monitor()); if !tuple.0 { window.set_outer_position(tuple.1); } } } if let Some(icon) = load_icon() { window.set_window_icon(Some(icon)); } #[cfg(target_os = "macos")] window_state::setup_macos_window(&window); let physical_size = window.inner_size(); // Cap to wgpu texture limits let capped_width = physical_size.width.min(MAX_TEXTURE_SIZE); let capped_height = physical_size.height.min(MAX_TEXTURE_SIZE); let viewport = Viewport::with_physical_size( Size::new(capped_width, capped_height), window.scale_factor(), ); let clipboard = Clipboard::connect(window.clone()); let backend = wgpu::util::backend_bits_from_env().unwrap_or_default(); let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: backend, ..Default::default() }); let surface = instance .create_surface(window.clone()) .expect("Create window surface"); let (format, adapter, device, queue, present_mode) = futures::futures::executor::block_on(async { let adapter = wgpu::util::initialize_adapter_from_env_or_default( &instance, Some(&surface), ) .await .expect("Create adapter"); let capabilities = surface.get_capabilities(&adapter); info!("GPU: {:?}", adapter.get_info().name); info!("Available present modes: {:?}", capabilities.present_modes); // Select non-blocking present mode to prevent frame.present() // from stalling the event loop on NVIDIA GPUs (strict FIFO queue). // Mailbox: non-blocking, replaces pending frame with latest (ideal) // Immediate: non-blocking, no VSync (fallback) // AutoNoVsync: auto-selects Mailbox or Immediate let present_mode = if capabilities.present_modes.contains(&wgpu::PresentMode::Mailbox) { info!("Selected Mailbox present mode (non-blocking)"); wgpu::PresentMode::Mailbox } else if capabilities.present_modes.contains(&wgpu::PresentMode::Immediate) { info!("Mailbox not available, selected Immediate present mode"); wgpu::PresentMode::Immediate } else { info!("Selected AutoNoVsync present mode (fallback)"); wgpu::PresentMode::AutoNoVsync }; let (device, queue) = adapter .request_device( &wgpu::DeviceDescriptor { label: Some("Main Device"), required_features: wgpu::Features::empty() | wgpu::Features::TEXTURE_COMPRESSION_BC, required_limits: wgpu::Limits::default(), }, None, ) .await .expect("Request device"); ( capabilities .formats .iter() .copied() .find(wgpu::TextureFormat::is_srgb) .or_else(|| { capabilities.formats.first().copied() }) .expect("Get preferred format"), adapter, device, queue, present_mode, ) }); surface.configure( &device, &wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, width: capped_width, height: capped_height, present_mode, alpha_mode: wgpu::CompositeAlphaMode::Auto, view_formats: vec![], desired_maximum_frame_latency: 2, }, ); // Create shared Arc instances of device and queue let device = Arc::new(device); let queue = Arc::new(queue); let backend = adapter.get_info().backend; // Initialize iced let mut debug_tool = Debug::new(); let config = ImageConfig { atlas_size: CONFIG.atlas_size, compression_strategy: CompressionStrategy::Bc1, }; let engine = Arc::new(Mutex::new(Engine::new( &adapter, &device, &queue, format, None, Some(config)))); { let engine_guard = engine.lock().unwrap(); engine_guard.create_image_cache(&device); } // Manually register fonts register_font_manually(include_bytes!("../assets/fonts/viewskater-fonts.ttf")); register_font_manually(include_bytes!("../assets/fonts/Iosevka-Regular-ascii.ttf")); register_font_manually(include_bytes!("../assets/fonts/Roboto-Regular.ttf")); // Create renderer with Rc let renderer = std::rc::Rc::new(Mutex::new(Renderer::new( &device, &engine.lock().unwrap(), Font::with_name("Roboto"), Pixels::from(16), ))); // Create the renderer request channel let (renderer_request_sender, renderer_request_receiver) = mpsc::channel(); // Pass a cloned Arc reference to DataViewer let mut shader_widget = DataViewer::new( Arc::clone(&device), Arc::clone(&queue), backend, renderer_request_sender, std::mem::replace(file_receiver, mpsc::channel().1), settings_path.as_deref(), std::mem::take(replay_config), ); shader_widget.last_monitor = window.current_monitor(); // Update state creation to lock renderer let mut renderer_guard = renderer.lock().unwrap(); let mut state = program::State::new( shader_widget, viewport.logical_size(), &mut *renderer_guard, &mut debug_tool, ); match CONFIG.window_state { WindowState::Maximized => { // On macOS, setup_macos_window() calls NSWindow.zoom() instead — // set_maximized() doesn't establish _savedFrame for unzoom #[cfg(not(target_os = "macos"))] window.set_maximized(true); }, WindowState::FullScreen => { let fullscreen = Some(winit::window::Fullscreen::Borderless(None)); state.queue_message(Message::ToggleFullScreen(true)); #[cfg(target_os = "macos")] { use iced_winit::winit::platform::macos::WindowExtMacOS; window.set_simple_fullscreen(fullscreen.is_some()); } #[cfg(not(target_os = "macos"))] { window.set_fullscreen(fullscreen); } }, _ => {}, } // Set control flow event_loop.set_control_flow(ControlFlow::Poll); let (p, worker) = iced_winit::Proxy::new(proxy.clone()); let Ok(executor) = iced_futures::backend::native::tokio::Executor::new() else { panic!("could not create runtime") }; executor.spawn(worker); let runtime = iced_futures::Runtime::new(executor, p); // Create a proper static waker let waker = { // Create a waker that does nothing struct NoopWaker; impl Wake for NoopWaker { fn wake(self: Arc) {} fn wake_by_ref(self: &Arc) {} } // Create a waker and leak it to make it 'static let waker_arc = Arc::new(NoopWaker); let waker = Waker::from(waker_arc); Box::leak(Box::new(waker)) }; let context = task::Context::from_waker(waker); // Create a new Ready state with the event_sender and control_receiver // Note: We don't clone the receiver as it's not clonable let event_sender = event_sender.clone(); // Move the control_receiver into the Ready state // We need to take ownership of it from the Loading state let control_receiver = std::mem::replace(control_receiver, std_mpsc::channel().1); *self = Self::Ready { window, device, queue, surface, format, present_mode, engine, renderer: renderer.clone(), state, cursor_position: None, modifiers: ModifiersState::default(), clipboard, runtime, viewport, resized: false, moved: false, redraw: true, last_title: String::new(), debug: false, debug_tool, _event_sender: event_sender, control_receiver, _context: context, custom_theme, renderer_request_receiver, }; } Self::Ready { .. } => { // Already initialized } } } fn window_event( &mut self, event_loop: &winit::event_loop::ActiveEventLoop, window_id: winit::window::WindowId, event: WindowEvent, ) { self.process_event( event_loop, Event::EventLoopAwakened(winit::event::Event::WindowEvent { window_id, event, }), ); } fn user_event( &mut self, event_loop: &winit::event_loop::ActiveEventLoop, action: Action, ) { self.process_event( event_loop, Event::EventLoopAwakened(winit::event::Event::UserEvent(action)), ); } fn about_to_wait( &mut self, event_loop: &winit::event_loop::ActiveEventLoop, ) { self.process_event( event_loop, Event::EventLoopAwakened(winit::event::Event::AboutToWait), ); } fn new_events( &mut self, event_loop: &winit::event_loop::ActiveEventLoop, cause: winit::event::StartCause, ) { self.process_event( event_loop, Event::EventLoopAwakened(winit::event::Event::NewEvents(cause)), ); } } let mut runner = Runner::Loading { proxy, event_sender, control_receiver, file_receiver, settings_path, replay_config, }; event_loop.run_app(&mut runner) } // Called in render method fn track_render_cycle() { if let Ok(mut time) = LAST_RENDER_TIME.lock() { let now = Instant::now(); let elapsed = now.duration_since(*time); *time = now; // Use the new diagnostics APIs let (fps, upload_secs, render_secs, min_render, max_render, frame_count) = get_image_rendering_diagnostics(); // Check for bottlenecks if elapsed.as_millis() > 50 { warn!("LONG FRAME DETECTED: Render time: {:?}", elapsed); warn!("Image stats: FPS={:.1}, Upload={:.2}ms, Render={:.2}ms, Min={:.2}ms, Max={:.2}ms", fps, upload_secs * 1000.0, render_secs * 1000.0, min_render * 1000.0, max_render * 1000.0); // Log detailed stats to console log_image_rendering_stats(); // Check if upload or render is the bottleneck if upload_secs > 0.050 { // 50ms threshold warn!("BOTTLENECK: GPU texture upload is slow: {:.2}ms avg", upload_secs * 1000.0); } if render_secs > 0.050 { // 50ms threshold warn!("BOTTLENECK: GPU render time is slow: {:.2}ms avg", render_secs * 1000.0); } } // Display diagnostics in UI during development if frame_count % 60 == 0 { // Periodic stats logging trace!("Image FPS: {:.1}, Upload: {:.2}ms, Render: {:.2}ms", fps, upload_secs * 1000.0, render_secs * 1000.0); } //trace!("TIMING: Render frame time: {:?}", elapsed); } } // Called when an async image completes fn track_async_delivery() { if let Ok(mut time) = LAST_ASYNC_DELIVERY_TIME.lock() { let now = Instant::now(); let elapsed = now.duration_since(*time); *time = now; trace!("TIMING: Interval time between async deliveries: {:?}", elapsed); } // Check image rendering FPS from custom iced_wgpu let image_fps = iced_wgpu::get_image_fps(); trace!("TIMING: Image FPS: {}", image_fps); // Also check phase alignment if let (Ok(render_time), Ok(async_time)) = (LAST_RENDER_TIME.lock(), LAST_ASYNC_DELIVERY_TIME.lock()) { let phase_diff = async_time.duration_since(*render_time); trace!("TIMING: Phase difference: {:?}", phase_diff); } } ================================================ FILE: src/menu.rs ================================================ #[cfg(target_os = "linux")] mod other_os { pub use iced_custom as iced; pub use iced_aw as iced_aw; // TODO: Change this to iced_aw_custom } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; pub use iced_aw as iced_aw; } #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; use iced_widget::{container, row, button, text}; use iced_winit::core::alignment; use iced_winit::core::{Padding, Element, Length, Border}; use iced_winit::core::border::Radius; use iced_widget::button::Style; use iced_winit::core::Theme as WinitTheme; use iced_winit::core::font::Font; use iced_wgpu::Renderer; use iced_wgpu::engine::CompressionStrategy; use iced_aw::menu::{self, Item, Menu}; use iced_aw::{menu_bar, menu_items}; use iced_aw::MenuBar; use iced_aw::style::{menu_bar::primary, Status}; use crate::{app::Message, DataViewer}; use crate::widgets::toggler; use crate::cache::img_cache::CacheStrategy; #[derive(Debug, Clone, PartialEq, Eq)] pub enum PaneLayout { SinglePane, DualPane, } const MENU_FONT_SIZE : u16 = 16; const MENU_ITEM_FONT_SIZE : u16 = 14; const _CARET_PATH : &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/svg/caret-right-fill.svg"); // Menu padding constants const MENU_PADDING_VERTICAL: u16 = 4; // padding for top and bottom const MENU_PADDING_HORIZONTAL: u16 = 8; // padding for left and right // A constant for the menu bar height that other components can reference pub const MENU_BAR_HEIGHT: f32 = (MENU_FONT_SIZE + MENU_PADDING_VERTICAL * 2) as f32; // 16px base height + 8px padding pub fn button_style(theme: &WinitTheme, status: button::Status, style_type: &str) -> Style { match style_type { "transparent" => Style { text_color: theme.extended_palette().background.base.text, background: Some(iced::Color::TRANSPARENT.into()), border: iced::Border { color: iced::Color::TRANSPARENT, width: 0.0, radius: Radius::new(0.0), }, ..Default::default() }, "labeled" => match status { button::Status::Active => Style { background: Some(theme.extended_palette().background.base.color.into()), text_color: theme.extended_palette().primary.weak.text, border: iced::Border { color: iced::Color::TRANSPARENT, width: 1.0, radius: Radius::new(0.0), }, ..Default::default() }, button::Status::Hovered => Style { background: Some(theme.extended_palette().background.weak.color.into()), text_color: theme.extended_palette().primary.weak.text, border: iced::Border { color: iced::Color::TRANSPARENT, width: 1.0, radius: Radius::new(0.0), }, ..Default::default() }, button::Status::Pressed => Style { background: Some(theme.extended_palette().background.weak.color.into()), text_color: theme.extended_palette().primary.weak.text, border: iced::Border { color: iced::Color::TRANSPARENT, width: 1.0, radius: Radius::new(0.0), }, ..Default::default() }, button::Status::Disabled => Style { background: Some(theme.extended_palette().background.base.color.into()), text_color: theme.extended_palette().background.strong.color, border: iced::Border { color: iced::Color::TRANSPARENT, width: 1.0, radius: Radius::new(0.0), }, ..Default::default() }, _ => Style::default(), }, _ => Style::default(), } } fn _transparent_style(theme: &WinitTheme, status: button::Status) -> Style { button_style(theme, status, "transparent") } fn labeled_style(theme: &WinitTheme, status: button::Status) -> Style { button_style(theme, status, "labeled") } fn default_style(_theme: &WinitTheme, _status: button::Status) -> Style { Style::default() } fn base_button<'a>( content: impl Into>, msg: Message, ) -> button::Button<'a, Message, WinitTheme, Renderer> { button(content) .style(labeled_style) .on_press(msg) } fn labeled_button<'a>( label: &'a str, text_size: u16, msg: Message, ) -> button::Button<'a, Message, WinitTheme, Renderer> { button( text(label) .size(text_size) .font(Font::with_name("Roboto")) ) .style(labeled_style) .on_press(msg) .width(Length::Fill) } //Proposal! fn labeled_button_maybe<'a>( label: &'a str, text_size: u16, msg: Option, ) -> button::Button<'a, Message, WinitTheme, Renderer> { button( text(label) .size(text_size) .font(Font::with_name("Roboto")) ) .style(labeled_style) .on_press_maybe(msg) .width(Length::Fill) } #[allow(dead_code)] fn nothing_button<'a>(label: &'a str, text_size: u16) -> button::Button<'a, Message, WinitTheme, Renderer> { button( text(label) .size(text_size) .font(Font::with_name("Roboto")) ) .style(default_style) } fn submenu_button(label: &str, text_size: u16) -> button::Button<'_, Message, WinitTheme, Renderer> { base_button( row![ text(label) .size(text_size) .font(Font::with_name("Roboto")) .width(Length::Fill) .align_y(alignment::Vertical::Center), text(">") .size(text_size) .width(Length::Shrink) .align_y(alignment::Vertical::Center), ] .align_y(iced::Alignment::Center), Message::Debug(label.into()), ) .width(Length::Fill) } pub fn menu_3<'a>(app: &DataViewer) -> Menu<'a, Message, WinitTheme, Renderer> { // Use platform-specific modifier text for menu items #[cfg(target_os = "macos")] let (single_pane_text, dual_pane_text) = ( if app.pane_layout == PaneLayout::SinglePane { "[x] Single Pane (Cmd+1)" } else { "[ ] Single Pane (Cmd+1)" }, if app.pane_layout == PaneLayout::DualPane { "[x] Dual Pane (Cmd+2)" } else { "[ ] Dual Pane (Cmd+2)" } ); #[cfg(not(target_os = "macos"))] let (single_pane_text, dual_pane_text) = ( if app.pane_layout == PaneLayout::SinglePane { "[x] Single Pane (Ctrl+1)" } else { "[ ] Single Pane (Ctrl+1)" }, if app.pane_layout == PaneLayout::DualPane { "[x] Dual Pane (Ctrl+2)" } else { "[ ] Dual Pane (Ctrl+2)" } ); let pane_layout_submenu = Menu::new(menu_items!( (labeled_button( single_pane_text, MENU_ITEM_FONT_SIZE, Message::TogglePaneLayout(PaneLayout::SinglePane) )) (labeled_button( dual_pane_text, MENU_ITEM_FONT_SIZE, Message::TogglePaneLayout(PaneLayout::DualPane) )) )) .max_width(180.0) .spacing(0.0); let controls_menu = Menu::new(menu_items!( (container( toggler::Toggler::new( Some(" Toggle Slider (Space)".into()), app.is_slider_dual, Message::ToggleSliderType, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() })) (container( toggler::Toggler::new( Some(" Toggle Footer (Tab)".into()), app.show_footer, Message::ToggleFooter, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() })) (container( toggler::Toggler::new( Some(" Horizontal Split (H)".into()), app.is_horizontal_split, Message::ToggleSplitOrientation, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() })) (container( toggler::Toggler::new( Some(" Toggle FPS Display".into()), app.show_fps, Message::ToggleFpsDisplay, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() })) (container( toggler::Toggler::new( Some(" Sync Zoom/Pan".into()), app.synced_zoom, Message::ToggleSyncedZoom, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() })) (container( toggler::Toggler::new( Some(" Toggle Mouse Wheel Zoom".into()), app.mouse_wheel_zoom, Message::ToggleMouseWheelZoom, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() })) )) .max_width(235.0) .spacing(0.0); // Create the formatted strings first as owned values let cpu_cache_text = if app.cache_strategy == CacheStrategy::Cpu { "[x] CPU cache" } else { "[ ] CPU cache" }; let gpu_cache_text = if app.cache_strategy == CacheStrategy::Gpu { "[x] GPU cache" } else { "[ ] GPU cache" }; let cache_type_submenu = Menu::new(menu_items!( (labeled_button( cpu_cache_text, MENU_ITEM_FONT_SIZE, Message::SetCacheStrategy(CacheStrategy::Cpu) )) (labeled_button( gpu_cache_text, MENU_ITEM_FONT_SIZE, Message::SetCacheStrategy(CacheStrategy::Gpu) )) )) .max_width(180.0) .spacing(0.0); // Create the formatted strings for compression strategy menu let no_compression_text = if app.compression_strategy == CompressionStrategy::None { "[x] No compression" } else { "[ ] No compression" }; let bc1_compression_text = if app.compression_strategy == CompressionStrategy::Bc1 { "[x] BC1 compression" } else { "[ ] BC1 compression" }; let compression_submenu = Menu::new(menu_items!( (labeled_button( no_compression_text, MENU_ITEM_FONT_SIZE, Message::SetCompressionStrategy(CompressionStrategy::None) )) (labeled_button( bc1_compression_text, MENU_ITEM_FONT_SIZE, Message::SetCompressionStrategy(CompressionStrategy::Bc1) )) )) .max_width(180.0) .spacing(0.0); Menu::new(menu_items!( (submenu_button("Pane Layout", MENU_ITEM_FONT_SIZE), pane_layout_submenu) (submenu_button("Controls", MENU_ITEM_FONT_SIZE), controls_menu) (submenu_button("Cache Type", MENU_ITEM_FONT_SIZE), cache_type_submenu) (submenu_button("Compression", MENU_ITEM_FONT_SIZE), compression_submenu) )) .max_width(120.0) .spacing(0.0) .offset(5.0) } pub fn menu_1<'a>(app: &DataViewer) -> Menu<'a, Message, WinitTheme, Renderer> { //Is there a better way? let is_image_loaded = app.panes.first().unwrap().current_image.len() > 0; #[cfg(target_os = "macos")] let menu_tpl_2 = |items| Menu::new(items).max_width(210.0).offset(5.0); #[cfg(not(target_os = "macos"))] let menu_tpl_2 = |items| Menu::new(items).max_width(200.0).offset(5.0); // Use platform-specific modifier text for menu items #[cfg(target_os = "macos")] let (open_folder_text, open_file_text, save_text, close_text, quit_text) = ( "Open Folder (Cmd+Shift+O)", "Open File (Cmd+O)", "Save (Cmd+S)", "Close (Cmd+W)", "Quit (Cmd+Q)", ); #[cfg(not(target_os = "macos"))] let (open_folder_text, open_file_text, save_text, close_text, quit_text) = ( "Open Folder (Ctrl+Shift+O)", "Open File (Ctrl+O)", "Save (Ctrl+S)", "Close (Ctrl+W)", "Quit (Ctrl+Q)", ); // Create submenu for "Open Folder" let open_folder_submenu = Menu::new(menu_items!( (labeled_button( "Pane 1 (Alt+1)", MENU_ITEM_FONT_SIZE, Message::OpenFolder(0) )) (labeled_button( "Pane 2 (Alt+2)", MENU_ITEM_FONT_SIZE, Message::OpenFolder(1) )) )) .max_width(180.0) .spacing(0.0); // Create submenu for "Open File" let open_file_submenu = Menu::new(menu_items!( (labeled_button( "Pane 1 (Shift+Alt+1)", MENU_ITEM_FONT_SIZE, Message::OpenFile(0) )) (labeled_button( "Pane 2 (Shift+Alt+2)", MENU_ITEM_FONT_SIZE, Message::OpenFile(1) )) )) .max_width(180.0) .spacing(0.0); menu_tpl_2(menu_items!(( submenu_button(open_folder_text, MENU_ITEM_FONT_SIZE), open_folder_submenu )( submenu_button(open_file_text, MENU_ITEM_FONT_SIZE), open_file_submenu )(labeled_button_maybe( save_text, MENU_ITEM_FONT_SIZE, is_image_loaded.then(|| Message::RequestSaveImage) ) )(labeled_button( close_text, MENU_ITEM_FONT_SIZE, Message::Close ))(labeled_button( quit_text, MENU_ITEM_FONT_SIZE, Message::Quit )))) } pub fn menu_help<'a>(_app: &DataViewer) -> Menu<'a, Message, WinitTheme, Renderer> { let menu_tpl_2 = |items| Menu::new(items).max_width(200.0).offset(5.0); menu_tpl_2( menu_items!( (labeled_button("Settings...", MENU_ITEM_FONT_SIZE, Message::ShowOptions)) (labeled_button("About", MENU_ITEM_FONT_SIZE, Message::ShowAbout)) (labeled_button("Show logs", MENU_ITEM_FONT_SIZE, Message::ShowLogs)) (labeled_button("Export debug logs", MENU_ITEM_FONT_SIZE, Message::ExportDebugLogs)) (labeled_button("Export all logs", MENU_ITEM_FONT_SIZE, Message::ExportAllLogs)) ) ) } pub fn build_menu(app: &DataViewer) -> MenuBar<'_, Message, WinitTheme, Renderer> { menu_bar!( ( container( text("File").size(MENU_FONT_SIZE).font(Font::with_name("Roboto")) ) .style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }) .padding([MENU_PADDING_VERTICAL, MENU_PADDING_HORIZONTAL]), menu_1(app) ) ( container( text("Controls").size(MENU_FONT_SIZE).font(Font::with_name("Roboto")),//.align_y(alignment::Vertical::Center) ) .style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }) .padding([MENU_PADDING_VERTICAL, MENU_PADDING_HORIZONTAL]), // // [top/bottom, left/right menu_3(app) ) ( container( text("Help").size(MENU_FONT_SIZE).font(Font::with_name("Roboto")) ) .style(|_theme: &WinitTheme| container::Style { text_color: Some(iced_core::Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }) .padding([MENU_PADDING_VERTICAL, MENU_PADDING_HORIZONTAL]), menu_help(app) ) ) //.spacing(10) // ref: https://github.com/iced-rs/iced_aw/blob/main/src/style/menu_bar.rs .draw_path(menu::DrawPath::Backdrop) .style(|theme: &WinitTheme, status: Status | menu::Style{ //menu_background: theme.extended_palette().background.weak.color.into(), menu_border: Border{ color: theme.extended_palette().background.weak.color, width: 1.0, radius: Radius::new(0.0) }, menu_background_expand: Padding::from(0.0), path_border: Border{ radius: Radius::new(0.0), ..Default::default() }, path: theme.extended_palette().background.weak.color.into(), ..primary(theme, status) }) } ================================================ FILE: src/navigation_keyboard.rs ================================================ #[warn(unused_imports)] #[cfg(target_os = "linux")] mod other_os { pub use iced_custom as iced; } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; } #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; use std::sync::Arc; use std::time::Instant; use iced::Task; use iced_wgpu::wgpu; use crate::app::Message; use crate::pane::{self, Pane, get_master_slider_value}; use crate::menu::PaneLayout; use crate::cache::img_cache::{CacheStrategy, LoadOperation, LoadOperationType, load_images_by_operation}; use crate::loading_status::LoadingStatus; use crate::pane::{IMAGE_RENDER_TIMES, IMAGE_RENDER_FPS}; use iced_wgpu::engine::CompressionStrategy; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; // Function to initialize image_load_state for all panes fn init_is_next_image_loaded(panes: &mut Vec<&mut Pane>, _pane_layout: &PaneLayout, _is_slider_dual: bool) { for pane in panes.iter_mut() { pane.is_next_image_loaded = false; pane.is_prev_image_loaded = false; } } fn init_is_prev_image_loaded(panes: &mut Vec<&mut Pane>, _pane_layout: &PaneLayout, _is_slider_dual: bool) { for pane in panes.iter_mut() { pane.is_prev_image_loaded = false; pane.is_next_image_loaded = false; } } // Function to check if all images are loaded for all panes #[allow(clippy::nonminimal_bool)] fn are_all_next_images_loaded(panes: &Vec<&mut Pane>, is_slider_dual: bool, _loading_status: &mut LoadingStatus) -> bool { if is_slider_dual { panes .iter() .filter(|pane| pane.is_selected) // Filter only selected panes .all(|pane| !pane.dir_loaded || (pane.dir_loaded && pane.is_next_image_loaded)) } else { panes.iter().all(|pane| !pane.dir_loaded || (pane.dir_loaded && pane.is_next_image_loaded)) } } #[allow(clippy::nonminimal_bool)] fn are_all_prev_images_loaded(panes: &Vec<&mut Pane>, is_slider_dual: bool, _loading_status: &mut LoadingStatus) -> bool { if is_slider_dual { panes .iter() .filter(|pane| pane.is_selected) // Filter only selected panes .all(|pane| !pane.dir_loaded || (pane.dir_loaded && pane.is_prev_image_loaded)) } else { panes.iter().all(|pane| !pane.dir_loaded || (pane.dir_loaded && pane.is_prev_image_loaded)) } } pub fn are_panes_cached_next(panes: &Vec<&mut Pane>, _pane_layout: &PaneLayout, _is_slider_dual: bool) -> bool { panes .iter() .filter(|pane| pane.is_selected) // Filter only selected panes .all(|pane| pane.is_pane_cached_next()) } pub fn are_panes_cached_prev(panes: &Vec<&mut Pane>, _pane_layout: &PaneLayout, _is_slider_dual: bool) -> bool { panes .iter() .filter(|pane| pane.is_selected) // Filter only selected panes .all(|pane| pane.is_pane_cached_prev()) } pub fn render_next_image_all(panes: &mut Vec<&mut Pane>, _pane_layout: &PaneLayout, is_slider_dual: bool) -> bool { let mut did_render_happen = false; // Render the next image for all panes for pane in panes.iter_mut() { let render_happened = pane.render_next_image(_pane_layout, is_slider_dual); debug!("render_next_image_all - render_happened: {}", render_happened); if render_happened { did_render_happen = true; } } // Only record rendering time if any pane actually rendered something if did_render_happen { // Record image rendering time if let Ok(mut render_times) = IMAGE_RENDER_TIMES.lock() { let now = Instant::now(); render_times.push(now); // Calculate image rendering FPS if render_times.len() > 1 { let oldest = render_times[0]; let elapsed = now.duration_since(oldest); if elapsed.as_secs_f32() > 0.0 { let fps = render_times.len() as f32 / elapsed.as_secs_f32(); // Store the current image rendering FPS if let Ok(mut image_fps) = IMAGE_RENDER_FPS.lock() { *image_fps = fps; } // Keep only recent frames (last 3 seconds) let cutoff = now - std::time::Duration::from_secs(3); render_times.retain(|&t| t > cutoff); // Sync back to iced_wgpu tracker for bidirectional sync // Convert Vec to VecDeque for iced_wgpu let timestamps: std::collections::VecDeque = render_times.iter().cloned().collect(); iced_wgpu::sync_image_tracker_timestamps(timestamps); } } } } did_render_happen } pub fn render_prev_image_all(panes: &mut Vec<&mut Pane>, _pane_layout: &PaneLayout, is_slider_dual: bool) -> bool { let mut did_render_happen = false; // First, check if the prev images of all panes are loaded. // If not, assume they haven't been loaded yet and wait for the next render cycle. // use if img_cache.is_some_at_index for pane in panes.iter_mut() { let img_cache = &mut pane.img_cache; img_cache.print_cache(); if !img_cache.is_some_at_index(0) { return false; } } // Render the prev image for all panes for pane in panes.iter_mut() { let render_happened = pane.render_prev_image(_pane_layout, is_slider_dual); if render_happened { debug!("render_prev_image_all - render_happened: {}", render_happened); did_render_happen = true; } } // Only record rendering time if any pane actually rendered something if did_render_happen { // Record image rendering time if let Ok(mut render_times) = IMAGE_RENDER_TIMES.lock() { let now = Instant::now(); render_times.push(now); // Calculate image rendering FPS if render_times.len() > 1 { let oldest = render_times[0]; let elapsed = now.duration_since(oldest); if elapsed.as_secs_f32() > 0.0 { let fps = render_times.len() as f32 / elapsed.as_secs_f32(); // Store the current image rendering FPS if let Ok(mut image_fps) = IMAGE_RENDER_FPS.lock() { *image_fps = fps; } // Keep only recent frames (last 3 seconds) let cutoff = now - std::time::Duration::from_secs(3); render_times.retain(|&t| t > cutoff); // Sync back to iced_wgpu tracker for bidirectional sync // Convert Vec to VecDeque for iced_wgpu let timestamps: std::collections::VecDeque = render_times.iter().cloned().collect(); iced_wgpu::sync_image_tracker_timestamps(timestamps); } } } } did_render_happen } #[allow(clippy::too_many_arguments)] pub fn load_next_images_all( device: &Arc, queue: &Arc, //is_gpu_supported: bool, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut Vec<&mut Pane>, pane_indices: Vec, loading_status: &mut LoadingStatus, _pane_layout: &PaneLayout, _is_slider_dual: bool, ) -> Task { // The updated get_target_indices_for_next function now returns Vec> let target_indices = get_target_indices_for_next(panes); debug!("load_next_images_all - target_indices: {:?}", target_indices); if target_indices.is_empty() { return Task::none(); } // Updated calculate_loading_conditions_for_next to work with Vec> debug!("load_next_images_all - target_indices: {:?}", target_indices); if let Some((next_image_indices_to_load, is_image_index_within_bounds, any_out_of_bounds)) = calculate_loading_conditions_for_next(panes, &target_indices) { // The LoadOperation::LoadNext variant now takes Vec> let load_next_operation = LoadOperation::LoadNext((pane_indices.clone(), next_image_indices_to_load.clone())); debug!("load_next_images_all - next_image_indices_to_load: {:?}", next_image_indices_to_load); if should_enqueue_loading( is_image_index_within_bounds, loading_status, &next_image_indices_to_load, &load_next_operation, panes, ) { debug!("load_next_images_all - should_enqueue_loading passed - any_out_of_bounds: {}", any_out_of_bounds); if any_out_of_bounds { // Now that we use the integration setup, can we disable this? loading_status.enqueue_image_load(LoadOperation::ShiftNext(( pane_indices, target_indices.clone(), ))); /**/ } else { loading_status.enqueue_image_load(load_next_operation); } debug!("load_next_images_all - running load_images_by_operation()"); return load_images_by_operation( //Some(Arc::clone(&device)), Some(Arc::clone(&queue)), is_gpu_supported, device, queue, cache_strategy, compression_strategy, panes, loading_status); } } Task::none() } fn calculate_loading_conditions_for_next( panes: &Vec<&mut Pane>, target_indices: &[Option], ) -> Option<(Vec>, bool, bool)> { let mut next_image_indices_to_load = Vec::new(); let mut is_image_index_within_bounds = false; let mut any_out_of_bounds = false; for (i, pane) in panes.iter().enumerate() { let img_cache = &pane.img_cache; let current_index_before_render = img_cache.current_index - 1; if !img_cache.image_paths.is_empty() && current_index_before_render < img_cache.image_paths.len() - 1 { match target_indices[i] { Some(next_image_index_to_load) => { if img_cache.is_image_index_within_bounds(next_image_index_to_load) { is_image_index_within_bounds = true; } if next_image_index_to_load as usize >= img_cache.num_files || img_cache.current_offset < 0 { any_out_of_bounds = true; } next_image_indices_to_load.push(Some(next_image_index_to_load)); } None => { any_out_of_bounds = true; next_image_indices_to_load.push(None); } } } else { next_image_indices_to_load.push(None); } } debug!("calculate_loading_conditions_for_next - next_image_indices_to_load: {:?}", next_image_indices_to_load); debug!("calculate_loading_conditions_for_next - is_image_index_within_bounds: {}", is_image_index_within_bounds); if next_image_indices_to_load.is_empty() { None } else { Some((next_image_indices_to_load, is_image_index_within_bounds, any_out_of_bounds)) } } fn get_target_indices_for_next(panes: &mut Vec<&mut Pane>) -> Vec> { panes.iter_mut().map(|pane| { if !pane.is_selected || !pane.dir_loaded { // Use None to indicate that the pane is not selected or loaded None } else { let cache = &mut pane.img_cache; debug!("get_target_indices_for_next - current_index: {}, current_offset: {}, cache_count: {}", cache.current_index, cache.current_offset, cache.cache_count); Some(cache.current_index as isize - cache.current_offset + cache.cache_count as isize + 1) } }).collect() } #[allow(clippy::too_many_arguments)] pub fn load_prev_images_all( device: &Arc, queue: &Arc, //is_gpu_supported: bool, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut Vec<&mut Pane>, pane_indices: Vec, loading_status: &mut LoadingStatus, _pane_layout: &PaneLayout, _is_slider_dual: bool, ) -> Task { let target_indices = get_target_indices_for_previous(panes); // NOTE: target_indices.is_empty() would return true on [None] #[allow(clippy::len_zero)] if target_indices.len() == 0 { return Task::none(); } if let Some((prev_image_indices_to_load, is_image_index_within_bounds, any_none_index)) = calculate_loading_conditions_for_previous(panes, &target_indices) { let load_prev_operation = LoadOperation::LoadPrevious((pane_indices.clone(), prev_image_indices_to_load.clone())); if should_enqueue_loading( is_image_index_within_bounds, loading_status, &prev_image_indices_to_load, &load_prev_operation, panes, ) { if any_none_index { // Now that we use the integration setup, can we disable this?? // Use ShiftPrevious if any index is out of bounds (`None`) loading_status.enqueue_image_load(LoadOperation::ShiftPrevious((pane_indices, target_indices))); } else { loading_status.enqueue_image_load(load_prev_operation); } return load_images_by_operation( device, queue, cache_strategy, compression_strategy, panes, loading_status); } } Task::none() } fn calculate_loading_conditions_for_previous( panes: &Vec<&mut Pane>, target_indices: &[Option], ) -> Option<(Vec>, bool, bool)> { let mut prev_image_indices_to_load = Vec::new(); let mut is_image_index_within_bounds = false; let mut any_none_index = false; for (i, pane) in panes.iter().enumerate() { let img_cache = &pane.img_cache; let current_index_before_render = img_cache.current_index + 1; if !img_cache.image_paths.is_empty() && current_index_before_render > 0 { match target_indices[i] { Some(prev_image_index_to_load) => { if img_cache.is_image_index_within_bounds(prev_image_index_to_load) { is_image_index_within_bounds = true; } prev_image_indices_to_load.push(Some(prev_image_index_to_load)); } None => { // If the index is out of bounds, mark as such any_none_index = true; is_image_index_within_bounds = true; // true because we need to enqueue Shift operations prev_image_indices_to_load.push(None); } } } else { // If the pane has no images or current index is invalid, mark as `None` prev_image_indices_to_load.push(None); } } // NOTE: prev_image_indices_to_load.is_empty() would return true for [None] #[allow(clippy::len_zero)] if prev_image_indices_to_load.len() == 0 { None } else { Some((prev_image_indices_to_load, is_image_index_within_bounds, any_none_index)) } } fn should_enqueue_loading( is_image_index_within_bounds: bool, loading_status: &LoadingStatus, image_indices_to_load: &[Option], load_operation: &LoadOperation, panes: &mut Vec<&mut Pane>, ) -> bool { is_image_index_within_bounds && loading_status.are_next_image_indices_in_queue(image_indices_to_load) && !loading_status.is_blocking_loading_ops_in_queue(panes, load_operation) } fn get_target_indices_for_previous(panes: &mut Vec<&mut Pane>) -> Vec> { panes.iter_mut().map(|pane| { if !pane.is_selected || !pane.dir_loaded { // Use None for panes that are not selected or not loaded None } else { let cache = &mut pane.img_cache; let target_index = (cache.current_index as isize + (-(cache.cache_count as isize) - cache.current_offset)) - 1; if target_index < 0 { // Use None for out-of-bounds values None } else { // Valid target index Some(target_index) } } }).collect() } #[allow(clippy::too_many_arguments)] pub fn move_right_all( device: &Arc, queue: &Arc, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut [pane::Pane], loading_status: &mut LoadingStatus, slider_value: &mut u16, pane_layout: &PaneLayout, is_slider_dual: bool, last_opened_pane: usize ) -> Task { debug!("##########MOVE_RIGHT_ALL()##########"); // Prevent movement while LoadPos is still in the queue loading_status.print_queue(); if loading_status.is_operation_in_queues(LoadOperationType::LoadPos) { debug!("move_right_all() - LoadPos operation in queue, skipping move_right_all()"); return Task::none(); } for pane in panes.iter_mut() { pane.print_state(); } debug!("move_right_all() - loading_status.is_next_image_loaded: {:?}", loading_status.is_next_image_loaded); // 1. Filter active panes // Collect mutable references to the panes that haven't reached the edge let mut panes_to_load: Vec<&mut pane::Pane> = vec![]; let mut indices_to_load: Vec = vec![]; for (index, pane) in panes.iter_mut().enumerate() { if pane.is_selected && pane.dir_loaded && pane.img_cache.current_index < pane.img_cache.image_paths.len() - 1 { panes_to_load.push(pane); indices_to_load.push(index); } } if panes_to_load.is_empty() { return Task::none(); } // 2. Rendering preparation // If all panes have been rendered, start rendering the next image; reset is_next_image_loaded if are_all_next_images_loaded(&panes_to_load, is_slider_dual, loading_status) { debug!("move_right_all() - all next images loaded"); init_is_next_image_loaded(&mut panes_to_load, pane_layout, is_slider_dual); loading_status.is_next_image_loaded = false; } let mut tasks = Vec::new(); // Load next images for all panes concurrently // Use the representative pane to determine the loading conditions // file_io::load_image_async() loads the next images for all panes at the same time, // so we can assume that the rest of the panes have the same loading conditions as the representative pane. debug!("move_right_all() - PROCESSING"); if !are_panes_cached_next(&panes_to_load, pane_layout, is_slider_dual) { debug!("move_right_all() - not all panes cached next, skipping..."); loading_status.print_queue(); // Since user tries to move the next image but image is not cached, enqueue loading the next image // Only do this when the loading queues don't have "Next" operations if !loading_status.is_operation_in_queues(LoadOperationType::LoadNext) || !loading_status.is_operation_in_queues(LoadOperationType::ShiftNext) { tasks.push(load_next_images_all( device, queue, cache_strategy, compression_strategy, &mut panes_to_load, indices_to_load.clone(), loading_status, pane_layout, is_slider_dual )); } // If panes already reached the edge, mark their is_next_image_loaded as true // We already picked the pane with the largest dir size, so we don't have to worry about the rest for pane in panes_to_load.iter_mut() { if pane.img_cache.current_index == pane.img_cache.image_paths.len() - 1 { pane.is_next_image_loaded = true; loading_status.is_next_image_loaded = true; } } } debug!("move_right_all() - are_all_next_images_loaded(): {}", are_all_next_images_loaded(&panes_to_load, is_slider_dual, loading_status)); debug!("move_right_all() - panes[0].is_next_image_loaded: {}", panes_to_load[0].is_next_image_loaded); if !are_all_next_images_loaded(&panes_to_load, is_slider_dual, loading_status) { let did_render_happen: bool = render_next_image_all(&mut panes_to_load, pane_layout, is_slider_dual); debug!("move_right_all() - did_render_happen = {}", did_render_happen); if did_render_happen { loading_status.is_next_image_loaded = true; for pane in panes_to_load.iter_mut() { pane.is_next_image_loaded = true; } tasks.push(load_next_images_all( device, queue, cache_strategy, compression_strategy, &mut panes_to_load, indices_to_load.clone(), loading_status, pane_layout, is_slider_dual )); } else { // Render failed because image not in cache - start loading timer for spinner debug!("SPINNER: move_right - render failed (not cached), setting timer"); for pane in panes_to_load.iter_mut() { if pane.loading_started_at.is_none() { pane.loading_started_at = Some(Instant::now()); } } } } let did_new_render_happen = are_all_next_images_loaded(&panes_to_load, is_slider_dual, loading_status); // Update master slider when !is_slider_dual if did_new_render_happen && !is_slider_dual || *pane_layout == PaneLayout::SinglePane { // Use the current_index of the pane with largest dir size *slider_value = (get_master_slider_value(&panes_to_load, pane_layout, is_slider_dual, last_opened_pane)) as u16; } // print tasks //debug!("move_right_all() - tasks count: {}", tasks.len()); Task::batch(tasks) } #[allow(clippy::too_many_arguments)] pub fn move_left_all( device: &Arc, queue: &Arc, //is_gpu_supported: bool, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut [pane::Pane], loading_status: &mut LoadingStatus, slider_value: &mut u16, pane_layout: &PaneLayout, is_slider_dual: bool, last_opened_pane: usize ) -> Task { debug!("##########MOVE_LEFT_ALL()##########"); // Prevent movement while LoadPos is still in the queue if loading_status.is_operation_in_queues(LoadOperationType::LoadPos) { debug!("move_left_all() - LoadPos operation in queue, skipping move_right_all()"); return Task::none(); } // Collect mutable references to the panes that haven't reached the edge let mut panes_to_load: Vec<&mut pane::Pane> = vec![]; let mut indices_to_load: Vec = vec![]; for (index, pane) in panes.iter_mut().enumerate() { if pane.is_selected && pane.dir_loaded && pane.img_cache.current_index > 0 { panes_to_load.push(pane); indices_to_load.push(index); } } if panes_to_load.is_empty() { return Task::none(); } // If all panes have been rendered, start rendering the next(prev) image; reset is_next_image_loaded if are_all_prev_images_loaded(&panes_to_load, is_slider_dual, loading_status) { debug!("move_left_all() - all prev images loaded"); for pane in panes_to_load.iter_mut() { pane.print_state(); } init_is_prev_image_loaded(&mut panes_to_load, pane_layout, is_slider_dual); loading_status.is_prev_image_loaded = false; } let mut tasks = Vec::new(); debug!("move_left_all() - PROCESSING"); if !are_panes_cached_prev(&panes_to_load, pane_layout, is_slider_dual) { debug!("move_left_all() - not all panes cached prev, skipping..."); loading_status.print_queue(); debug!("move_left_all() - loading_status.is_operation_in_queues(LoadOperationType::LoadPrevious): {}", loading_status.is_operation_in_queues(LoadOperationType::LoadPrevious)); debug!("move_left_all() - loading_status.is_operation_in_queues(LoadOperationType::ShiftPrevious): {}", loading_status.is_operation_in_queues(LoadOperationType::ShiftPrevious)); // Since user tries to move the next image but image is not cached, enqueue loading the next image // Only do this when the loading queues don't have "Prev" operations if !loading_status.is_operation_in_queues(LoadOperationType::LoadPrevious) || !loading_status.is_operation_in_queues(LoadOperationType::ShiftPrevious) { tasks.push(load_prev_images_all( device, queue, cache_strategy, compression_strategy, &mut panes_to_load, indices_to_load.clone(), loading_status, pane_layout, is_slider_dual)); } // If panes already reached the edge, mark their is_next_image_loaded as true // We already picked the pane with the largest dir size, so we don't have to worry about the rest for pane in panes_to_load.iter_mut() { if pane.img_cache.current_index == 0 { pane.is_prev_image_loaded = true; loading_status.is_prev_image_loaded = true; } } } debug!("move_left_all() - are_all_prev_images_loaded(): {}", are_all_prev_images_loaded(&panes_to_load, is_slider_dual, loading_status)); debug!("move_left_all() - loading_status.is_prev_image_loaded: {}", loading_status.is_prev_image_loaded); if !are_all_prev_images_loaded(&panes_to_load, is_slider_dual, loading_status) { debug!("move_left_all() - setting prev image..."); let did_render_happen: bool = render_prev_image_all(&mut panes_to_load, pane_layout, is_slider_dual); for pane in panes_to_load.iter_mut() { pane.print_state(); } if did_render_happen { loading_status.is_prev_image_loaded = true; debug!("move_left_all() - loading prev images..."); tasks.push(load_prev_images_all( device, queue, cache_strategy, compression_strategy, &mut panes_to_load, indices_to_load.clone(), loading_status, pane_layout, is_slider_dual)); } else { // Render failed because image not in cache - start loading timer for spinner debug!("SPINNER: move_left - render failed (not cached), setting timer"); for pane in panes_to_load.iter_mut() { if pane.loading_started_at.is_none() { pane.loading_started_at = Some(Instant::now()); } } } } let did_new_render_happen = are_all_prev_images_loaded(&panes_to_load, is_slider_dual, loading_status); // Update master slider when !is_slider_dual if did_new_render_happen && !is_slider_dual || *pane_layout == PaneLayout::SinglePane { *slider_value = (get_master_slider_value(&panes_to_load, pane_layout, is_slider_dual, last_opened_pane) ) as u16; } Task::batch(tasks) } ================================================ FILE: src/navigation_slider.rs ================================================ #[warn(unused_imports)] #[cfg(target_os = "linux")] mod other_os { pub use iced_custom as iced; } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; } #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; #[allow(unused_imports)] use log::{Level, trace, debug, info, warn, error}; use image::codecs::png::PngEncoder; use image::ImageEncoder; use image::ExtendedColorType; use std::sync::atomic::{AtomicUsize, Ordering}; #[allow(unused_imports)] use std::time::{Instant, Duration}; use once_cell::sync::Lazy; use std::sync::Mutex; use iced::widget::image::Handle; use iced_wgpu::wgpu; use iced::Task; use std::io; use crate::Arc; use crate::pane; use crate::cache::img_cache::{LoadOperation, load_all_images_in_queue}; use crate::widgets::shader::scene::Scene; use crate::loading_status::LoadingStatus; use crate::app::Message; use crate::cache::img_cache::{CachedData, CacheStrategy, ImageMetadata}; use crate::cache::cache_utils::{load_image_resized_sync, create_gpu_texture}; use crate::pane::IMAGE_RENDER_TIMES; use crate::pane::IMAGE_RENDER_FPS; use iced_wgpu::engine::CompressionStrategy; pub static LATEST_SLIDER_POS: AtomicUsize = AtomicUsize::new(0); #[allow(dead_code)] static LAST_SLIDER_LOAD: Lazy> = Lazy::new(|| Mutex::new(Instant::now())); const _THROTTLE_INTERVAL_MS: u64 = 100; // Default throttle interval fn load_full_res_image( device: &Arc, queue: &Arc, is_gpu_supported: bool, compression_strategy: CompressionStrategy, panes: &mut [pane::Pane], pane_index: isize, pos: usize, ) -> Task { debug!("load_full_res_image: Reloading full-resolution image at pos {}", pos); // Create a list of pane indices to process let pane_indices: Vec = if pane_index == -1 { // Process all panes with loaded directories panes.iter().enumerate() .filter_map(|(idx, pane)| if pane.dir_loaded { Some(idx) } else { None }) .collect() } else { // Process only the specified pane vec![pane_index as usize] }; // Process each pane in the list for idx in pane_indices { if let Some(pane) = panes.get_mut(idx) { let img_cache = &mut pane.img_cache; let img_path = match img_cache.image_paths.get(pos) { Some(path) => path.clone(), None => { debug!("Image path missing for pos {} in pane {}", pos, idx); continue; } }; // Determine the target index inside cache array let target_index: usize; if pos < img_cache.cache_count { target_index = pos; img_cache.current_offset = -(img_cache.cache_count as isize - pos as isize); } else if pos >= img_cache.image_paths.len() - img_cache.cache_count { target_index = img_cache.cache_count + (img_cache.cache_count as isize - ((img_cache.image_paths.len()-1) as isize - pos as isize)) as usize; img_cache.current_offset = img_cache.cache_count as isize - ((img_cache.image_paths.len()-1) as isize - pos as isize); } else { target_index = img_cache.cache_count; img_cache.current_offset = 0; } // Check if this pane has GPU support by checking if device and queue are available let has_gpu_support = is_gpu_supported && pane.device.is_some() && pane.queue.is_some(); if has_gpu_support { // GPU-based loading // Get or create a texture let mut texture = img_cache.cached_data.get(pos) .and_then(|opt| opt.as_ref()) .and_then(|cached| match cached { CachedData::Gpu(tex) => Some(tex.clone()), _ => None, }) .unwrap_or_else(|| Arc::new(create_gpu_texture(device, 1, 1, compression_strategy))); // Load the full-resolution image synchronously // Use the archive cache if provided let mut archive_guard = pane.archive_cache.lock().unwrap(); let archive_cache_opt = if pane.has_compressed_file { Some(&mut *archive_guard) } else { None }; if let Err(err) = load_image_resized_sync(&img_path, false, device, queue, &mut texture, compression_strategy, archive_cache_opt) { debug!("Failed to load full-res image {} for pane {idx}: {err}", img_path.file_name()); continue; } // Get file size while we still have the archive lock let file_size = if pane.has_compressed_file { crate::file_io::get_file_size(&img_path, Some(&mut *archive_guard)) } else { crate::file_io::get_file_size(&img_path, None) }; drop(archive_guard); // Release the lock // Store the full-resolution texture in the cache let loaded_image = CachedData::Gpu(texture.clone()); img_cache.cached_data[target_index] = Some(loaded_image.clone()); img_cache.cached_image_indices[target_index] = pos as isize; img_cache.current_index = pos; debug!("load_full_res_image: Set current_index = {} for pane {}", pos, idx); // Get dimensions from the loaded texture let tex_size = texture.size(); let metadata = ImageMetadata::new(tex_size.width, tex_size.height, file_size); img_cache.cached_metadata[target_index] = Some(metadata.clone()); // Update the currently displayed image pane.current_image = loaded_image; pane.current_image_index = Some(pos); pane.current_image_metadata = Some(metadata); debug!("load_full_res_image: Set current_image_index = {} for pane {}", pos, idx); pane.scene = Some(Scene::new(Some(&CachedData::Gpu(Arc::clone(&texture))))); pane.scene.as_mut().unwrap().update_texture(Arc::clone(&texture)); } else { // CPU-based loading // Lock archive cache for both file size and image loading let mut archive_guard = pane.archive_cache.lock().unwrap(); // Get file size (needs archive cache for archives) let file_size = if pane.has_compressed_file { crate::file_io::get_file_size(&img_path, Some(&mut *archive_guard)) } else { crate::file_io::get_file_size(&img_path, None) }; // Load the image let archive_cache = if pane.has_compressed_file { Some(&mut *archive_guard) } else { None }; match img_cache.load_image(pos, archive_cache) { Ok(cached_data) => { // Get dimensions from loaded image data let (width, height) = cached_data.dimensions(); let metadata = Some(ImageMetadata::new(width, height, file_size)); // Store in cache and update current image img_cache.cached_data[target_index] = Some(cached_data.clone()); img_cache.cached_metadata[target_index] = metadata.clone(); img_cache.cached_image_indices[target_index] = pos as isize; img_cache.current_index = pos; debug!("load_full_res_image (CPU): Set current_index = {} for pane {}", pos, idx); // Update the currently displayed image pane.current_image = cached_data.clone(); pane.current_image_index = Some(pos); pane.current_image_metadata = metadata; debug!("load_full_res_image (CPU): Set current_image_index = {} for pane {}", pos, idx); // Update scene if using CPU-based cached data if let CachedData::Cpu(_img) = &cached_data { // Create a new scene with the CPU image pane.scene = Some(Scene::new(Some(&cached_data))); // Ensure texture is created for the new scene if device/queue available if let (Some(device), Some(queue)) = (&pane.device, &pane.queue) { if let Some(scene) = &mut pane.scene { scene.ensure_texture(device, queue, pane.pane_id); } } } }, Err(err) => { debug!("Failed to load CPU image for pane {}: {}", idx, err); continue; } } } debug!("Full-res image loaded successfully at pos {} for pane {}", pos, idx); } } Task::none() } #[allow(clippy::too_many_arguments)] fn get_loading_tasks_slider( device: &Arc, queue: &Arc, _is_gpu_supported: bool, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut [pane::Pane], loading_status: &mut LoadingStatus, pane_index: usize, pos: usize, ) -> Vec> { let mut tasks = Vec::new(); if let Some(pane) = panes.get_mut(pane_index) { let img_cache = &pane.img_cache; let cache_count = img_cache.cache_count; let last_index = cache_count * 2 + 1; // Collect pairs of (image index, cache position) let mut target_indices_and_cache = Vec::new(); // Example: Handling first cache window case if pos < cache_count { for i in 0..last_index { let image_index = i as isize; let cache_pos = i; target_indices_and_cache.push(Some((image_index, cache_pos))); } } // Example: Handling the last cache window case else if pos >= img_cache.image_paths.len() - cache_count { for i in 0..last_index { let image_index = (img_cache.image_paths.len() - last_index + i) as isize; let cache_pos = i; target_indices_and_cache.push(Some((image_index, cache_pos))); } } // Example: Default handling for neighboring images else { let center_index = cache_count; for i in 0..cache_count { let next_image_index = pos + i + 1; let prev_image_index = (pos as isize - i as isize - 1).max(0); // Enqueue neighboring images with cache positions if next_image_index < img_cache.image_paths.len() { target_indices_and_cache.push(Some((next_image_index as isize, center_index + i + 1))); } if prev_image_index >= 0 { target_indices_and_cache.push(Some((prev_image_index, center_index - i - 1))); } } } // Enqueue the batched LoadPos operation with (image index, cache position) pairs let load_operation = LoadOperation::LoadPos((pane_index, target_indices_and_cache)); loading_status.enqueue_image_load(load_operation); // Start loading timer for spinner display pane.loading_started_at = Some(std::time::Instant::now()); debug!("SPINNER: Set loading_started_at for pane {} (slider navigation)", pane_index); debug!("get_loading_tasks_slider - loading_status.loading_queue: {:?}", loading_status.loading_queue); loading_status.print_queue(); // Generate loading tasks let local_tasks = load_all_images_in_queue( device, queue, cache_strategy, compression_strategy, panes, loading_status ); tasks.push(local_tasks); } debug!("get_loading_tasks_slider - loading_status addr: {:p}", loading_status); loading_status.print_queue(); tasks } #[allow(clippy::too_many_arguments)] pub fn load_remaining_images( device: &Arc, queue: &Arc, is_gpu_supported: bool, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut [pane::Pane], loading_status: &mut LoadingStatus, pane_index: isize, pos: usize, ) -> Task { // Clear the global loading queue loading_status.reset_image_load_queue(); loading_status.reset_image_being_loaded_queue(); let mut tasks = Vec::new(); // First, load the full-resolution image **synchronously** let full_res_task = load_full_res_image(device, queue, is_gpu_supported, compression_strategy, panes, pane_index, pos); tasks.push(full_res_task); // Debug: Check current_index after load_full_res_image let check_pane_idx = if pane_index == -1 { 0 } else { pane_index as usize }; if let Some(pane) = panes.get(check_pane_idx) { debug!("load_remaining_images: After load_full_res_image, pane[{}].current_index = {}", check_pane_idx, pane.img_cache.current_index); } // Then, load the neighboring images asynchronously if pane_index == -1 { // Dynamic loading: load the central image synchronously, and others asynchronously let cache_indices: Vec = panes .iter() .enumerate() .filter_map(|(cache_index, pane)| if pane.dir_loaded { Some(cache_index) } else { None }) .collect(); for cache_index in cache_indices { let local_tasks = get_loading_tasks_slider( device, queue, is_gpu_supported, cache_strategy, compression_strategy, panes, loading_status, cache_index, pos ); debug!("load_remaining_images - local_tasks.len(): {}", local_tasks.len()); tasks.extend(local_tasks); } } else if let Some(pane) = panes.get_mut(pane_index as usize) { if pane.dir_loaded { let local_tasks = get_loading_tasks_slider( device, queue, is_gpu_supported, cache_strategy, compression_strategy, panes, loading_status, pane_index as usize, pos); tasks.extend(local_tasks); } else { tasks.push(Task::none()); } } debug!("load_remaining_images - loading_status addr: {:p}", loading_status); loading_status.print_queue(); debug!("load_remaining_images - tasks.len(): {}", tasks.len()); Task::batch(tasks) } /// Load neighboring images asynchronously after initial single-image load /// This is used during initialization to load the cache window in the background #[allow(clippy::too_many_arguments)] pub fn load_initial_neighbors( device: &Arc, queue: &Arc, is_gpu_supported: bool, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, panes: &mut [pane::Pane], loading_status: &mut LoadingStatus, pane_index: usize, pos: usize, ) -> Task { // Clear the global loading queue loading_status.reset_image_load_queue(); loading_status.reset_image_being_loaded_queue(); debug!("load_initial_neighbors: Loading neighbors for pane {} at pos {}", pane_index, pos); // Get loading tasks for neighbors (skips the central image which is already loaded) let tasks = get_loading_tasks_slider( device, queue, is_gpu_supported, cache_strategy, compression_strategy, panes, loading_status, pane_index, pos, ); Task::batch(tasks) } // Async loading task for Image widget - updated to include pane_idx and archive cache pub async fn create_async_image_widget_task( img_path: crate::cache::img_cache::PathSource, pos: usize, pane_idx: usize, archive_cache: Option>> ) -> Result<(usize, usize, Handle, (u32, u32), u64), (usize, usize)> { // Start overall timer let task_start = std::time::Instant::now(); // Start file reading timer let read_start = std::time::Instant::now(); // Dispatch based on PathSource type let bytes_result = match &img_path { crate::cache::img_cache::PathSource::Filesystem(path) => { // Direct filesystem reading - no archive cache needed std::fs::read(path) }, crate::cache::img_cache::PathSource::Archive(_) | crate::cache::img_cache::PathSource::Preloaded(_) => { // Archive content requires archive cache if let Some(cache_arc) = archive_cache { match cache_arc.lock() { Ok(mut cache) => { crate::file_io::read_image_bytes(&img_path, Some(&mut *cache)) }, Err(_) => { Err(std::io::Error::other("Archive cache lock failed")) }, } } else { Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Archive cache required for archive/preloaded content")) } } }; // Measure file reading time let read_time = read_start.elapsed(); trace!("PERF: File read time for pos {}: {:?}", pos, read_time); match bytes_result { Ok(bytes) => { // Start handle creation timer let handle_start = std::time::Instant::now(); // Capture file size before moving bytes let file_size = bytes.len() as u64; // Extract image dimensions efficiently using header-only read use std::io::Cursor; use image::ImageReader; let dimensions = match ImageReader::new(Cursor::new(&bytes)) .with_guessed_format() .ok() .and_then(|r| r.into_dimensions().ok()) { Some(dims) => dims, None => { // If we can't decode, return error return Err((pane_idx, pos)); } }; // Convert directly to Handle without resizing let handle = iced::widget::image::Handle::from_bytes(bytes.clone()); // Measure handle creation time let handle_time = handle_start.elapsed(); trace!("PERF: Handle creation time for pos {}: {:?}", pos, handle_time); // Measure total function time let total_time = task_start.elapsed(); trace!("PERF: Total async task time for pos {}: {:?}", pos, total_time); Ok((pane_idx, pos, handle, dimensions, file_size)) }, Err(_) => Err((pane_idx, pos)), } } pub fn update_pos( panes: &mut [pane::Pane], pane_index: isize, pos: usize, use_async: bool, throttle: bool ) -> Task { // Store the latest position in the atomic variable for reference LATEST_SLIDER_POS.store(pos, Ordering::SeqCst); // Determine if we should process this update based on throttling settings let should_process = if throttle { // Platform-specific throttling - use different thresholds for Linux #[cfg(target_os = "linux")] const PLATFORM_THROTTLE_MS: u64 = 10; #[cfg(not(target_os = "linux"))] const PLATFORM_THROTTLE_MS: u64 = _THROTTLE_INTERVAL_MS; // Throttling logic during rapid slider movement - With safety check let mut last_load = LAST_SLIDER_LOAD.lock().unwrap(); let now = Instant::now(); // Enhanced safety check for time inconsistencies let elapsed = match now.checked_duration_since(*last_load) { Some(duration) => duration, None => { // Update last_load to current time to avoid repeated issues *last_load = now; // Return a zero duration to ensure we process this event Duration::from_millis(PLATFORM_THROTTLE_MS) } }; if elapsed.as_millis() >= PLATFORM_THROTTLE_MS as u128 { *last_load = now; true } else { false } } else { // No throttling, always process true }; // Skip processing if we're throttling if !should_process { return Task::none(); } if use_async { // Collect tasks for all applicable panes let mut tasks = Vec::new(); // Determine which panes to update let pane_indices: Vec = if pane_index == -1 { // Master slider - update all panes with loaded directories panes.iter().enumerate() .filter_map(|(idx, pane)| if pane.dir_loaded { Some(idx) } else { None }) .collect() } else { // Individual pane slider - update only that pane vec![pane_index as usize] }; // Create async image loading task for each pane for idx in pane_indices { if let Some(pane) = panes.get(idx) { if pane.dir_loaded && !pane.img_cache.image_paths.is_empty() && pos < pane.img_cache.image_paths.len() { debug!("#####################update_pos - Creating async image loading task for pane {}", idx); // Get only the single path we need from each pane let img_path = pane.img_cache.image_paths[pos].clone(); // Check if the pane has compressed files and get the archive cache let archive_cache = if pane.has_compressed_file { Some(Arc::clone(&pane.archive_cache)) } else { None }; // Create task for this pane let pane_task = Task::perform( create_async_image_widget_task(img_path, pos, idx, archive_cache), Message::SliderImageWidgetLoaded ); tasks.push(pane_task); } } } // Return all tasks batched together if !tasks.is_empty() { return Task::batch(tasks); } Task::none() } else if pane_index == -1 { // Perform dynamic loading: // Load the image at pos (center) synchronously, // and then load the rest of the images within the cache window asynchronously let mut tasks = Vec::new(); for (cache_index, pane) in panes.iter_mut().enumerate() { if pane.dir_loaded { //match load_current_slider_image(pane, pos) { match load_current_slider_image_widget(pane, pos) { Ok(()) => { debug!("update_pos - Image loaded successfully for pane {}", cache_index); } Err(err) => { debug!("update_pos - Error loading image for pane {}: {}", cache_index, err); } } } else { tasks.push(Task::none()); } } Task::batch(tasks) } else { let pane_index = pane_index as usize; let pane = &mut panes[pane_index]; if pane.dir_loaded { //match load_current_slider_image(pane, pos) { match load_current_slider_image_widget(pane, pos) { Ok(()) => { debug!("update_pos - Image loaded successfully for pane {}", pane_index); } Err(err) => { debug!("update_pos - Error loading image for pane {}: {}", pane_index, err); } } } Task::none() } } #[allow(dead_code)] /// Loads the image at pos synchronously into the cache using CpuScene fn load_current_slider_image(pane: &mut pane::Pane, pos: usize) -> Result<(), io::Error> { // Load the image at pos synchronously let img_cache = &mut pane.img_cache; // Update indices in the cache let target_index: usize; if pos < img_cache.cache_count { target_index = pos; img_cache.current_offset = -(img_cache.cache_count as isize - pos as isize); } else if pos >= img_cache.image_paths.len() - img_cache.cache_count { target_index = img_cache.cache_count + (img_cache.cache_count as isize - ((img_cache.image_paths.len()-1) as isize - pos as isize)) as usize; img_cache.current_offset = img_cache.cache_count as isize - ((img_cache.image_paths.len()-1) as isize - pos as isize); } else { target_index = img_cache.cache_count; img_cache.current_offset = 0; } img_cache.cached_image_indices[target_index] = pos as isize; img_cache.current_index = pos; // Get direct access to the image file for CPU loading let img_path = match img_cache.image_paths.get(pos) { Some(path) => path, None => return Err(io::Error::new(io::ErrorKind::NotFound, "Image path not found")), }; // Always load from file directly for best slider performance // Use the safe load_original_image function to prevent crashes with oversized images let mut archive_guard = pane.archive_cache.lock().unwrap(); // For PathBuf, we'll use ArchiveCache if the pane has compressed files let archive_cache = if pane.has_compressed_file { Some(&mut *archive_guard) } else { None }; match crate::cache::cache_utils::load_original_image(img_path, archive_cache) { Ok(img) => { // Resize the image to smaller dimensions for slider /*let resized = img.resize( 800, // Width for slider 600, // Height for slider image::imageops::FilterType::Triangle );*/ // Create the CPU bytes let mut bytes: Vec = Vec::new(); if let Err(err) = { let encoder = PngEncoder::new(std::io::Cursor::new(&mut bytes)); encoder.write_image( img.as_bytes(), img.width(), img.height(), ExtendedColorType::Rgba8 ) } { debug!("Failed to encode slider image: {}", err); return Err(io::Error::other("Failed to encode image")); } // Update the current image to CPU data pane.current_image = CachedData::Cpu(bytes.clone()); pane.current_image_index = Some(pos); pane.slider_scene = Some(Scene::new(Some(&CachedData::Cpu(bytes.clone())))); // Ensure texture is created for CPU images if let Some(device) = &pane.device { if let Some(queue) = &pane.queue { if let Some(scene) = &mut pane.slider_scene { scene.ensure_texture(device, queue, pane.pane_id); } } } Ok(()) }, Err(err) => { debug!("Failed to open image for slider: {}", err); Err(io::Error::other(format!("Failed to open image: {}", err))) } } } /// Loads the image at pos synchronously into the cache using Iced's image widget fn load_current_slider_image_widget(pane: &mut pane::Pane, pos: usize ) -> Result<(), io::Error> { // Load the image at pos synchronously into the center position of cache // Assumes that the image at pos is already in the cache let img_cache = &mut pane.img_cache; let mut archive_guard = pane.archive_cache.lock().unwrap(); let archive_cache = if pane.has_compressed_file { Some(&mut *archive_guard) } else { None }; // Get image path for file size lookup let img_path = img_cache.image_paths.get(pos).cloned(); match img_cache.load_image(pos, archive_cache) { Ok(image) => { let target_index: usize; if pos < img_cache.cache_count { target_index = pos; img_cache.current_offset = -(img_cache.cache_count as isize - pos as isize); } else if pos >= img_cache.image_paths.len() - img_cache.cache_count { //target_index = img_cache.image_paths.len() - pos; target_index = img_cache.cache_count + (img_cache.cache_count as isize - ((img_cache.image_paths.len()-1) as isize - pos as isize)) as usize; img_cache.current_offset = img_cache.cache_count as isize - ((img_cache.image_paths.len()-1) as isize - pos as isize); } else { target_index = img_cache.cache_count; img_cache.current_offset = 0; } // Get dimensions from the loaded image and file size from filesystem let (width, height) = image.dimensions(); let file_size = if let Some(ref path_source) = img_path { match path_source { crate::cache::img_cache::PathSource::Filesystem(path) => { std::fs::metadata(path).map(|m| m.len()).unwrap_or(0) }, _ => { // For archive/preloaded, use the cached data length as approximation image.len() as u64 } } } else { 0 }; let metadata = Some(ImageMetadata::new(width, height, file_size)); debug!("SliderChanged metadata: pos={}, dims={}x{}, size={}", pos, width, height, file_size); img_cache.cached_data[target_index] = Some(image); img_cache.cached_metadata[target_index] = metadata.clone(); img_cache.cached_image_indices[target_index] = pos as isize; img_cache.current_index = pos; // Update pane's current metadata pane.current_image_metadata = metadata; // Use the new method that ensures we get CPU data let mut archive_guard = pane.archive_cache.lock().unwrap(); let archive_cache = if pane.has_compressed_file { Some(&mut *archive_guard) } else { None }; match img_cache.get_initial_image_as_cpu(archive_cache) { Ok(bytes) => { pane.slider_image = Some(iced::widget::image::Handle::from_bytes(bytes)); // Record image rendering time for FPS calculation if let Ok(mut render_times) = IMAGE_RENDER_TIMES.lock() { let now = Instant::now(); render_times.push(now); // Calculate image rendering FPS if render_times.len() > 1 { let oldest = render_times[0]; let elapsed = now.duration_since(oldest); if elapsed.as_secs_f32() > 0.0 { let fps = render_times.len() as f32 / elapsed.as_secs_f32(); // Store the current image rendering FPS if let Ok(mut image_fps) = IMAGE_RENDER_FPS.lock() { *image_fps = fps; } // Keep only recent frames (last 3 seconds) let cutoff = now - std::time::Duration::from_secs(3); render_times.retain(|&t| t > cutoff); } } } Ok(()) }, Err(err) => { debug!("Failed to get CPU image data for slider: {}", err); // Fallback: load directly from file if let Some(img_path) = img_cache.image_paths.get(pos) { // Dispatch based on PathSource type let bytes_result = match img_path { crate::cache::img_cache::PathSource::Filesystem(path) => { // Direct filesystem reading - no archive cache needed std::fs::read(path) }, crate::cache::img_cache::PathSource::Archive(_) | crate::cache::img_cache::PathSource::Preloaded(_) => { // Archive content requires archive cache let mut archive_cache = pane.archive_cache.lock().unwrap(); crate::file_io::read_image_bytes(img_path, Some(&mut *archive_cache)) } }; match bytes_result { Ok(bytes) => { pane.slider_image = Some(iced::widget::image::Handle::from_bytes(bytes)); // Record image rendering time for FPS calculation (for fallback path) if let Ok(mut render_times) = IMAGE_RENDER_TIMES.lock() { let now = Instant::now(); render_times.push(now); // Calculate image rendering FPS if render_times.len() > 1 { let oldest = render_times[0]; let elapsed = now.duration_since(oldest); if elapsed.as_secs_f32() > 0.0 { let fps = render_times.len() as f32 / elapsed.as_secs_f32(); // Store the current image rendering FPS if let Ok(mut image_fps) = IMAGE_RENDER_FPS.lock() { *image_fps = fps; } // Keep only recent frames (last 3 seconds) let cutoff = now - std::time::Duration::from_secs(3); render_times.retain(|&t| t > cutoff); } } } Ok(()) }, Err(err) => Err(io::Error::other( format!("Failed to read image file for slider: {}", err) )) } } else { Err(io::Error::new( io::ErrorKind::NotFound, "Image path not found for slider", )) } } } } Err(err) => { debug!("update_pos(): Error loading image: {}", err); Err(err) } } } ================================================ FILE: src/pane.rs ================================================ use std::error::Error; use std::path::Path; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Instant; use std::fs::File; use once_cell::sync::Lazy; use iced_widget::{container, text}; use iced_winit::core::Length; use iced_winit::runtime::Task; use iced_wgpu::Renderer; use iced_winit::core::Theme as WinitTheme; use iced_wgpu::wgpu; use iced_core::image::{Handle, FilterMethod}; #[cfg(feature = "coco")] use iced_core::Vector; use iced_widget::center; use crate::cache::img_cache::PathSource; use crate::config::CONFIG; use crate::app::Message; use crate::cache::img_cache::{CachedData, CacheStrategy, ImageCache, ImageMetadata}; use crate::archive_cache::ArchiveCache; use crate::file_io::supported_image; use crate::archive_cache::ArchiveType; use crate::file_io::ALLOWED_COMPRESSED_FILES; use crate::menu::PaneLayout; use crate::widgets::viewer; use crate::widgets::shader::{image_shader::ImageShader, scene::Scene, cpu_scene::CpuScene}; use crate::file_io::{self, is_file, is_directory, get_file_index, ImageError}; use crate::utils::mem; use iced_wgpu::engine::CompressionStrategy; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; pub static IMAGE_RENDER_TIMES: Lazy>> = Lazy::new(|| { Mutex::new(Vec::with_capacity(120)) }); pub static IMAGE_RENDER_FPS: Lazy> = Lazy::new(|| { Mutex::new(0.0) }); pub struct Pane { pub directory_path: Option, pub dir_loaded: bool, pub img_cache: ImageCache, pub current_image: CachedData, // <-- Now stores either CPU or GPU image pub current_image_index: Option, // Track which index current_image contains pub current_image_metadata: Option, // Metadata for current image (resolution, file size) pub is_next_image_loaded: bool, // whether the next image in cache is loaded pub is_prev_image_loaded: bool, // whether the previous image in cache is loaded pub slider_value: u16, pub prev_slider_value: u16, pub is_selected: bool, pub is_selected_cache: bool, pub scene: Option, pub slider_scene: Option, // Make sure this is Scene, not CpuScene pub slider_image: Option, pub slider_image_dimensions: Option<(u32, u32)>, // Store dimensions for annotation rendering pub slider_image_position: Option, // Track which position slider_image represents pub backend: wgpu::Backend, pub device: Option>, pub queue: Option>, pub pane_id: usize, // New field for pane identification pub compression_strategy: CompressionStrategy, pub mouse_wheel_zoom: bool, pub ctrl_pressed: bool, pub has_compressed_file: bool, pub archive_cache: Arc>, pub max_loading_queue_size: usize, pub max_being_loaded_queue_size: usize, #[cfg(feature = "coco")] pub show_bboxes: bool, // Toggle for showing COCO bounding boxes #[cfg(feature = "coco")] pub show_masks: bool, // Toggle for showing COCO segmentation masks #[cfg(feature = "coco")] pub zoom_scale: f32, // Current zoom scale for bbox rendering #[cfg(feature = "coco")] pub zoom_offset: Vector, // Current pan offset for bbox rendering pub loading_started_at: Option, // When loading started (for spinner delay) } impl Default for Pane { fn default() -> Self { Self { directory_path: None, dir_loaded: false, img_cache: ImageCache::default(), current_image: CachedData::Cpu(vec![]), // Default to empty CPU image current_image_index: None, current_image_metadata: None, is_next_image_loaded: true, is_prev_image_loaded: true, slider_value: 0, prev_slider_value: 0, is_selected: true, is_selected_cache: true, scene: None, slider_scene: None, // Default to None backend: wgpu::Backend::Vulkan, device: None, queue: None, slider_image: None, slider_image_dimensions: None, slider_image_position: None, pane_id: 0, // Default to pane 0 compression_strategy: CompressionStrategy::None, mouse_wheel_zoom: false, ctrl_pressed: false, has_compressed_file: false, archive_cache: Arc::new(Mutex::new(ArchiveCache::new())), max_loading_queue_size: CONFIG.max_loading_queue_size, max_being_loaded_queue_size: CONFIG.max_being_loaded_queue_size, #[cfg(feature = "coco")] show_bboxes: false, #[cfg(feature = "coco")] show_masks: false, #[cfg(feature = "coco")] zoom_scale: 1.0, #[cfg(feature = "coco")] zoom_offset: Vector::default(), loading_started_at: None, } } } impl Pane { pub fn new( device: Arc, queue: Arc, backend: wgpu::Backend, pane_id: usize, compression_strategy: CompressionStrategy ) -> Self { let scene = Scene::new(None); // Create a dedicated CPU-based scene for slider let slider_scene = Scene::CpuScene(CpuScene::new(vec![], true)); Self { directory_path: None, dir_loaded: false, img_cache: ImageCache::default(), current_image: CachedData::Cpu(vec![]), current_image_index: None, current_image_metadata: None, is_next_image_loaded: true, is_prev_image_loaded: true, slider_value: 0, prev_slider_value: 0, is_selected: true, is_selected_cache: true, scene: Some(scene), slider_scene: Some(slider_scene), backend, device: Some(device), queue: Some(queue), slider_image: None, slider_image_dimensions: None, slider_image_position: None, pane_id, // Use the provided pane_id compression_strategy, mouse_wheel_zoom: false, ctrl_pressed: false, has_compressed_file: false, archive_cache: Arc::new(Mutex::new(ArchiveCache::new())), max_loading_queue_size: CONFIG.max_loading_queue_size, max_being_loaded_queue_size: CONFIG.max_being_loaded_queue_size, #[cfg(feature = "coco")] show_bboxes: false, #[cfg(feature = "coco")] show_masks: false, #[cfg(feature = "coco")] zoom_scale: 1.0, #[cfg(feature = "coco")] zoom_offset: Vector::default(), loading_started_at: None, } } pub fn print_state(&self) { debug!("directory_path: {:?}, dir_loaded: {:?}, is_next_image_loaded: {:?}, is_prev_image_loaded: {:?}, slider_value: {:?}, prev_slider_value: {:?}", self.directory_path, self.dir_loaded, self.is_next_image_loaded, self.is_prev_image_loaded, self.slider_value, self.prev_slider_value); // TODO: print `current_image` too //self.img_cache.print_state(); } pub fn reset_state(&mut self) { // Clear the scene which holds texture references self.scene = None; // Clear the slider scene holding texture references self.slider_scene = None; // Drop the current images self.current_image = CachedData::Cpu(vec![]); self.current_image_index = None; self.current_image_metadata = None; self.slider_image = None; self.slider_image_position = None; // Explicitly reset the image cache self.img_cache.clear_cache(); self.img_cache = ImageCache::default(); // Clear archive cache preloaded data and reset state if let Ok(mut archive_cache) = self.archive_cache.lock() { archive_cache.clear_preloaded_data(); } // Reset archive cache to a fresh instance self.archive_cache = Arc::new(Mutex::new(ArchiveCache::new())); self.has_compressed_file = false; // Reset other state self.directory_path = None; self.dir_loaded = false; self.is_next_image_loaded = true; self.is_prev_image_loaded = true; self.is_selected = true; self.slider_value = 0; self.prev_slider_value = 0; } /// Set up scene with cached image data (GPU texture, BC1 compressed, or CPU bytes) /// This is a common pattern used across render_next_image, render_prev_image, /// initialize_dir_path, and initialize_with_paths fn setup_scene_for_image(&mut self, cached_data: &CachedData) { match cached_data { CachedData::Gpu(texture) => { debug!("Setting up scene with GPU texture"); self.current_image = CachedData::Gpu(Arc::clone(texture)); self.scene = Some(Scene::new(Some(&CachedData::Gpu(Arc::clone(texture))))); self.scene.as_mut().unwrap().update_texture(Arc::clone(texture)); } CachedData::BC1(texture) => { debug!("Setting up scene with BC1 compressed texture"); self.current_image = CachedData::BC1(Arc::clone(texture)); self.scene = Some(Scene::new(Some(&CachedData::BC1(Arc::clone(texture))))); self.scene.as_mut().unwrap().update_texture(Arc::clone(texture)); } CachedData::Cpu(image_bytes) => { debug!("Setting up scene with CPU image"); self.current_image = CachedData::Cpu(image_bytes.clone()); self.scene = Some(Scene::new(Some(&CachedData::Cpu(image_bytes.clone())))); // Ensure texture is created for CPU images if let Some(device) = &self.device { if let Some(queue) = &self.queue { if let Some(scene) = &mut self.scene { scene.ensure_texture(device, queue, self.pane_id); } } } } } } pub fn resize_panes(panes: &mut Vec, new_size: usize) { if new_size > panes.len() { // Add new panes with proper IDs for i in panes.len()..new_size { if let Some(first_pane) = panes.first() { if let (Some(device), Some(queue)) = (&first_pane.device, &first_pane.queue) { panes.push(Pane::new( Arc::clone(device), Arc::clone(queue), first_pane.backend, i, // Use the index as the pane_id first_pane.compression_strategy )); } else { // Fallback if no device/queue available panes.push(Pane { pane_id: i, .. Pane::default() }); } } else { // Fallback if no existing panes panes.push(Pane { pane_id: i, .. Pane::default() }); } } } else if new_size < panes.len() { // Truncate panes, preserving the first `new_size` elements panes.truncate(new_size); } } pub fn is_pane_cached_next(&self) -> bool { debug!("is_selected: {}, dir_loaded: {}, is_next_image_loaded: {}, img_cache.is_next_cache_index_within_bounds(): {}, img_cache.loading_queue.len(): {}, img_cache.being_loaded_queue.len(): {}", self.is_selected, self.dir_loaded, self.is_next_image_loaded, self.img_cache.is_next_cache_index_within_bounds(), self.img_cache.loading_queue.len(), self.img_cache.being_loaded_queue.len()); // May need to consider whether current_index reached the end of the list self.is_selected && self.dir_loaded && self.img_cache.is_next_cache_index_within_bounds() && self.img_cache.loading_queue.len() < self.max_loading_queue_size && self.img_cache.being_loaded_queue.len() < self.max_being_loaded_queue_size } pub fn is_pane_cached_prev(&self) -> bool { debug!("is_selected: {}, dir_loaded: {}, is_prev_image_loaded: {}, img_cache.is_prev_cache_index_within_bounds(): {}, img_cache.loading_queue.len(): {}, img_cache.being_loaded_queue.len(): {}", self.is_selected, self.dir_loaded, self.is_prev_image_loaded, self.img_cache.is_prev_cache_index_within_bounds(), self.img_cache.loading_queue.len(), self.img_cache.being_loaded_queue.len()); self.is_selected && self.dir_loaded && self.img_cache.is_prev_cache_index_within_bounds() && self.img_cache.loading_queue.len() < self.max_loading_queue_size && self.img_cache.being_loaded_queue.len() < self.max_being_loaded_queue_size } pub fn render_next_image(&mut self, pane_layout: &PaneLayout, is_slider_dual: bool) -> bool { let mut did_render_happen = false; self.img_cache.print_cache(); // Safely compute target index as isize let target_index_isize = self.img_cache.cache_count as isize + self.img_cache.current_offset + 1; if target_index_isize >= 0 { let next_image_index_to_render = (self.img_cache.cache_count as isize + self.img_cache.current_offset + 1) as usize; debug!("BEGINE RENDERING NEXT: next_image_index_to_render: {} current_index: {}, current_offset: {}", next_image_index_to_render, self.img_cache.current_index, self.img_cache.current_offset); // Clone the cached image to release the borrow before calling setup_scene_for_image let cached_image = self.img_cache.get_image_by_index(next_image_index_to_render) .cloned(); match cached_image { Ok(data) => { self.setup_scene_for_image(&data); } Err(_) => { debug!("Failed to retrieve next cached image."); return false; } } self.img_cache.current_offset += 1; // Since the next image is loaded and rendered, mark the is_next_image_loaded flag self.is_next_image_loaded = true; did_render_happen = true; // Handle current_index if self.img_cache.current_index < self.img_cache.image_paths.len() - 1 { self.img_cache.current_index += 1; } // Track which index current_image contains (after current_index is updated) self.current_image_index = Some(self.img_cache.current_index); // Update metadata from cache self.current_image_metadata = self.img_cache.get_initial_metadata().cloned(); if *pane_layout == PaneLayout::DualPane && is_slider_dual { self.slider_value = self.img_cache.current_index as u16; } debug!("END RENDERING NEXT: current_index: {}, current_offset: {}", self.img_cache.current_index, self.img_cache.current_offset); } did_render_happen } pub fn render_prev_image(&mut self, pane_layout: &PaneLayout, is_slider_dual: bool) -> bool { let mut did_render_happen = false; // Render the previous one right away // Avoid loading around the edges if self.img_cache.cache_count as isize + self.img_cache.current_offset > 0 && self.img_cache.is_some_at_index((self.img_cache.cache_count as isize + self.img_cache.current_offset) as usize) { let next_image_index_to_render = self.img_cache.cache_count as isize + (self.img_cache.current_offset - 1); debug!("RENDERING PREV: next_image_index_to_render: {} current_index: {}, current_offset: {}", next_image_index_to_render, self.img_cache.current_index, self.img_cache.current_offset); if self.img_cache.is_image_index_within_bounds(next_image_index_to_render) { // Clone the cached image to release the borrow before calling setup_scene_for_image let cached_image = self.img_cache.get_image_by_index(next_image_index_to_render as usize) .cloned(); match cached_image { Ok(data) => { self.setup_scene_for_image(&data); } Err(_) => { debug!("Failed to retrieve prev cached image."); return false; } } self.img_cache.current_offset -= 1; assert!(self.img_cache.current_offset >= -(self.img_cache.cache_count as isize)); // Check against actual cache size, not static CONFIG // Since the prev image is loaded and rendered, mark the is_prev_image_loaded flag self.is_prev_image_loaded = true; if self.img_cache.current_index > 0 { self.img_cache.current_index -= 1; } // Track which index current_image contains (after current_index is updated) self.current_image_index = Some(self.img_cache.current_index); // Update metadata from cache self.current_image_metadata = self.img_cache.get_initial_metadata().cloned(); debug!("RENDERED PREV: current_index: {}, current_offset: {}", self.img_cache.current_index, self.img_cache.current_offset); if *pane_layout == PaneLayout::DualPane && is_slider_dual { self.slider_value = self.img_cache.current_index as u16; } did_render_happen = true; } } did_render_happen } #[allow(clippy::too_many_arguments)] #[allow(unused_assignments)] pub fn initialize_dir_path( &mut self, device: &Arc, queue: &Arc, _is_gpu_supported: bool, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, pane_layout: &PaneLayout, pane_file_lengths: &[usize], _pane_index: usize, path: &PathBuf, is_slider_dual: bool, slider_value: &mut u16, cache_size: usize, archive_cache_size: u64, archive_warning_threshold_mb: u64, ) -> Task { mem::log_memory("Before pane initialization"); let mut file_paths: Vec = Vec::new(); let mut initial_index: usize = 0; let longest_file_length = pane_file_lengths.iter().max().unwrap_or(&0); // compressed file if path.extension().is_some_and(|ex| ALLOWED_COMPRESSED_FILES.contains(&ex.to_ascii_lowercase().to_str().unwrap_or(""))) { let archive; match path.extension().unwrap().to_ascii_lowercase().to_str() { Some("zip") => { let mut archive_cache = self.archive_cache.lock().unwrap(); match read_zip_path(path, &mut file_paths, &mut archive_cache, archive_cache_size) { Ok(_) => { archive = ArchiveType::Zip; }, Err(e) => { error!("Failed to read zip file: {e}"); return Task::none(); }, } }, Some("rar") => { let mut archive_cache = self.archive_cache.lock().unwrap(); match read_rar_path(path, &mut file_paths, &mut archive_cache, archive_cache_size) { Ok(_) => { archive = ArchiveType::Rar; }, Err(e) => { error!("Failed to read rar file: {e}"); return Task::none(); }, } } Some("7z") => { let mut archive_cache = self.archive_cache.lock().unwrap(); match read_7z_path(path, &mut file_paths, &mut archive_cache, archive_cache_size, archive_warning_threshold_mb) { Ok(_) => { archive = ArchiveType::SevenZ; }, Err(e) => { error!("Failed to read 7z file: {e}"); return Task::none(); }, } } _ => { error!("File extension not supported"); return Task::none(); } } if file_paths.is_empty() { error!("No supported images found in {path:?}"); return Task::none(); } self.directory_path = Some(path.display().to_string()); file_paths.sort_by(|a, b| alphanumeric_sort::compare_str( a.file_name(), b.file_name() )); self.has_compressed_file = true; self.archive_cache.lock().unwrap().set_current_archive(path.to_path_buf(), archive); } else { // Get directory path and image files let (dir_path, paths_result) = if is_file(path) { debug!("Dropped path is a file: {}", path.display()); let directory = path.parent().unwrap_or(Path::new("")); let dir = directory.to_string_lossy().to_string(); debug!("Parent directory path: {}", dir); // First try to read the parent directory let parent_result = file_io::get_image_paths(Path::new(&dir)); match parent_result { Ok(paths) => { // Parent directory access succeeded debug!("✅ SUCCESS: Parent directory access succeeded, found {} images", paths.len()); (dir.clone(), Ok(paths)) } Err(ImageError::DirectoryError(e)) => { // Parent directory access failed (likely sandboxed), create a single-file list debug!("❌ Parent directory access denied (error: {}), creating single-file cache", e); debug!("This is likely due to App Store sandboxing - only the selected file is accessible"); let file_path = path.to_string_lossy().to_string(); let single_file_list = vec![path.clone()]; debug!("Single-file cache created with 1 image: {}", file_path); (file_path.clone(), Ok(single_file_list)) } Err(e) => { // Other error, try single file as fallback debug!("❌ Other error reading directory: {}, falling back to single file", e); let file_path = path.to_string_lossy().to_string(); let single_file_list = vec![path.clone()]; (file_path.clone(), Ok(single_file_list)) } } } else if is_directory(path) { debug!("Dropped path is a directory: {}", path.display()); let dir = path.to_string_lossy().to_string(); (dir, file_io::get_image_paths(path)) } else { error!("❌ Dropped path does not exist or cannot be accessed: {}", path.display()); return Task::none(); }; // Handle the result from get_image_paths file_paths = match paths_result { Ok(paths) => paths.iter().map(|item| { // Regular filesystem files - use Filesystem variant PathSource::Filesystem(item.to_path_buf()) }).collect::>(), Err(ImageError::NoImagesFound) => { error!("No supported images found in directory"); // TODO: Show a message to the user that no images were found return Task::none(); } Err(e) => { error!("Error reading directory: {e}"); // TODO: Show error message to user return Task::none(); } }; self.directory_path = Some(dir_path); // Determine initial index and update slider if is_file(path) { let file_index = get_file_index(&file_paths.iter().map(|item| { item.path().clone() }).collect::>(), path); initial_index = match file_index { Some(idx) => { debug!("File index: {}", idx); idx } None => { debug!("File index not found"); return Task::none(); } }; } self.has_compressed_file = false; }; // Calculate if directory size is bigger than other panes let is_dir_size_bigger: bool = if *pane_layout == PaneLayout::SinglePane || *pane_layout == PaneLayout::DualPane && is_slider_dual { true } else { file_paths.len() >= *longest_file_length }; debug!("longest_file_length: {:?}, is_dir_size_bigger: {:?}", longest_file_length, is_dir_size_bigger); // Sort debug!("File paths: {}", file_paths.len()); self.dir_loaded = true; // Clone device and queue before passing to ImageCache to avoid the move let device_clone = Arc::clone(device); let queue_clone = Arc::clone(queue); // Instantiate a new image cache based on GPU support let mut img_cache = ImageCache::new( &file_paths, cache_size, cache_strategy, compression_strategy, initial_index, Some(device_clone), Some(queue_clone), ); // Track memory before loading initial images mem::log_memory("Pane::initialize_dir_path: Before loading initial image"); // Load only the first/dropped image synchronously for immediate display let mut archive_guard = self.archive_cache.lock().unwrap(); let archive_cache = if self.has_compressed_file { Some(&mut *archive_guard) } else { None }; if let Err(e) = img_cache.load_single_image(archive_cache) { error!("Failed to load initial image: {}", e); drop(archive_guard); // Release the lock before returning return Task::none(); } drop(archive_guard); // Release the lock after loading mem::log_memory("Pane::initialize_dir_path: After loading initial image"); for (index, image_option) in img_cache.cached_data.iter().enumerate() { match image_option { Some(image_bytes) => { let image_info = format!("Image {} - Index {} - Size: {} bytes", index, img_cache.cached_image_indices[index], image_bytes.len()); debug!("{}", image_info); } None => { let no_image_info = format!("No image at index {}", index); debug!("{}", no_image_info); } } } if let Ok(initial_image) = img_cache.get_initial_image() { // Track which index this initial image represents self.current_image_index = Some(img_cache.current_index); // Set metadata for the initial image self.current_image_metadata = img_cache.get_initial_metadata().cloned(); self.setup_scene_for_image(initial_image); } else { debug!("Failed to retrieve initial image"); } // Update slider value let current_slider_value = initial_index as u16; debug!("current_slider_value: {:?}", current_slider_value); if is_slider_dual { self.slider_value = current_slider_value; } else if *pane_layout == PaneLayout::SinglePane || *pane_layout == PaneLayout::DualPane && is_dir_size_bigger { *slider_value = current_slider_value; } debug!("slider_value: {:?}", *slider_value); let file_paths = img_cache.image_paths.clone(); debug!("file_paths.len() {:?}", file_paths.len()); self.img_cache = img_cache; debug!("img_cache.cache_count {:?}", self.img_cache.cache_count); // Async neighbor loading is handled by the caller (app.rs) which has access to loading_status Task::none() } /// Initialize pane with pre-enumerated file paths (Issue #73 - NFS performance fix) /// Called after async directory enumeration completes #[allow(clippy::too_many_arguments)] pub fn initialize_with_paths( &mut self, device: &Arc, queue: &Arc, _is_gpu_supported: bool, cache_strategy: CacheStrategy, compression_strategy: CompressionStrategy, pane_layout: &PaneLayout, pane_file_lengths: &[usize], _pane_index: usize, image_paths: Vec, directory_path: String, initial_index: usize, is_slider_dual: bool, slider_value: &mut u16, cache_size: usize, ) { mem::log_memory("Before pane initialization with paths"); // Convert PathBuf to PathSource let file_paths: Vec = image_paths.iter() .map(|p| PathSource::Filesystem(p.clone())) .collect(); if file_paths.is_empty() { error!("No images in enumerated paths"); return; } self.directory_path = Some(directory_path); self.has_compressed_file = false; let longest_file_length = pane_file_lengths.iter().max().unwrap_or(&0); // Calculate if directory size is bigger than other panes let is_dir_size_bigger: bool = if *pane_layout == PaneLayout::SinglePane || *pane_layout == PaneLayout::DualPane && is_slider_dual { true } else { file_paths.len() >= *longest_file_length }; debug!("File paths: {}", file_paths.len()); self.dir_loaded = true; // Clone device and queue before passing to ImageCache let device_clone = Arc::clone(device); let queue_clone = Arc::clone(queue); // Instantiate a new image cache with pre-enumerated paths let mut img_cache = ImageCache::new( &file_paths, cache_size, cache_strategy, compression_strategy, initial_index, Some(device_clone), Some(queue_clone), ); mem::log_memory("Pane::initialize_with_paths: Before loading initial image"); // Load only the first/dropped image synchronously for immediate display // No archive cache since this is for regular directories if let Err(e) = img_cache.load_single_image(None) { error!("Failed to load initial image: {}", e); return; } mem::log_memory("Pane::initialize_with_paths: After loading initial image"); // Set up scene with initial image if let Ok(initial_image) = img_cache.get_initial_image() { self.current_image_index = Some(img_cache.current_index); self.current_image_metadata = img_cache.get_initial_metadata().cloned(); self.setup_scene_for_image(initial_image); } else { debug!("Failed to retrieve initial image"); } // Update slider value let current_slider_value = initial_index as u16; debug!("current_slider_value: {:?}", current_slider_value); if is_slider_dual { self.slider_value = current_slider_value; } else if *pane_layout == PaneLayout::SinglePane || *pane_layout == PaneLayout::DualPane && is_dir_size_bigger { *slider_value = current_slider_value; } self.img_cache = img_cache; debug!("img_cache.cache_count {:?}", self.img_cache.cache_count); } pub fn build_ui_container(&self, use_slider_image_for_render: bool, is_horizontal_split: bool, double_click_threshold_ms: u16, use_nearest_filter: bool) -> iced_winit::core::Element<'_, Message, WinitTheme, Renderer> { if self.dir_loaded { if use_slider_image_for_render && self.slider_image.is_some() { // Use regular Image widget during slider movement (much faster) let image_handle = self.slider_image.clone().unwrap(); container( center( viewer::Viewer::new(image_handle) .content_fit(iced_winit::core::ContentFit::Contain) .filter_method(if use_nearest_filter { FilterMethod::Nearest } else { FilterMethod::Linear }) ) ) .width(Length::Fill) .height(Length::Fill) .into() } else if let Some(scene) = &self.scene { #[cfg(feature = "coco")] let mut shader_widget = ImageShader::new(Some(scene)) .width(Length::Fill) .height(Length::Fill) .content_fit(iced_winit::core::ContentFit::Contain) .horizontal_split(is_horizontal_split) .with_interaction_state(self.mouse_wheel_zoom, self.ctrl_pressed) .double_click_threshold_ms(double_click_threshold_ms) .use_nearest_filter(use_nearest_filter); #[cfg(not(feature = "coco"))] let shader_widget = ImageShader::new(Some(scene)) .width(Length::Fill) .height(Length::Fill) .content_fit(iced_winit::core::ContentFit::Contain) .horizontal_split(is_horizontal_split) .with_interaction_state(self.mouse_wheel_zoom, self.ctrl_pressed) .double_click_threshold_ms(double_click_threshold_ms) .use_nearest_filter(use_nearest_filter); // Set up zoom change callback for COCO bbox rendering #[cfg(feature = "coco")] { shader_widget = shader_widget .pane_index(self.pane_id) .on_zoom_change(|pane_idx, scale, offset| { Message::CocoAction(crate::coco::widget::CocoMessage::ZoomChanged( pane_idx, scale, offset )) }); } let shader_widget = shader_widget; container(center(shader_widget)) .width(Length::Fill) .height(Length::Fill) .into() } else { container(text("No image loaded")) .width(Length::Fill) .height(Length::Fill) .into() } } else { container(text("")) .width(Length::Fill) .height(Length::Fill) .into() } } } #[allow(dead_code)] pub fn get_pane_with_largest_dir_size(panes: &Vec<&mut Pane>) -> isize { let mut max_dir_size = 0; let mut max_dir_size_index = -1; for (i, pane) in panes.iter().enumerate() { if pane.dir_loaded && pane.img_cache.num_files > max_dir_size { max_dir_size = pane.img_cache.num_files; max_dir_size_index = i as isize; } } max_dir_size_index } pub fn get_master_slider_value(panes: &[&mut Pane], _pane_layout: &PaneLayout, _is_slider_dual: bool, _last_opened_pane: usize) -> usize { let mut max_dir_size = 0; let mut max_dir_size_index = 0; for (i, pane) in panes.iter().enumerate() { if pane.dir_loaded && pane.img_cache.num_files > max_dir_size { max_dir_size = pane.img_cache.num_files; max_dir_size_index = i; } } let pane = &panes[max_dir_size_index]; pane.img_cache.current_index } fn read_zip_path(path: &PathBuf, file_paths: &mut Vec, archive_cache: &mut ArchiveCache, archive_cache_size: u64) -> Result<(), Box> { use std::io::Read; let mut files = Vec::new(); let mut archive = zip::ZipArchive::new(std::io::BufReader::new( File::open(path)?))?; let mut image_names = Vec::new(); // First pass: collect all image files and their sizes for i in 0..archive.len() { let file = archive.by_index(i)?; if file.is_file() && supported_image(file.name()) { let filename = file.name().to_string(); image_names.push(filename); files.push(file.size()); } } // Set up the archive cache for this ZIP file archive_cache.set_current_archive(path.clone(), ArchiveType::Zip); // Determine if we'll preload this archive (small archives get preloaded) let will_preload = files.iter().sum::() < archive_cache_size; // Second pass: create PathSource variants and optionally preload for name in &image_names { let path_buf = PathBuf::from(name); if will_preload { // Small archive - preload the data and use Preloaded variant let mut buffer = Vec::new(); archive.by_name(name)?.read_to_end(&mut buffer)?; archive_cache.add_preloaded_data(name.clone(), buffer); file_paths.push(PathSource::Preloaded(path_buf)); } else { // Large archive - use Archive variant for on-demand loading file_paths.push(PathSource::Archive(path_buf)); } } Ok(()) } fn read_rar_path(path: &PathBuf, file_paths: &mut Vec, archive_cache: &mut ArchiveCache, archive_cache_size: u64) -> Result<(), Box> { let archive = unrar::Archive::new(path) .open_for_listing()?; let mut files = Vec::new(); let mut image_names = Vec::new(); // First pass: collect all image files and their sizes for result in archive { let header = result?; let name = header.filename.to_str().unwrap_or(""); if header.is_file() && supported_image(name) { let filename = name.to_string(); image_names.push(filename); files.push(header.unpacked_size); } } // Set up the archive cache for this RAR file archive_cache.set_current_archive(path.clone(), ArchiveType::Rar); // Determine if we'll preload this archive (small archives get preloaded) let will_preload = files.iter().sum::() < archive_cache_size; // Second pass: create PathSource variants and optionally preload for name in &image_names { let path_buf = PathBuf::from(name); if will_preload { // Small archive - preload the data and use Preloaded variant let mut archive = unrar::Archive::new(path) .open_for_processing()?; while let Some(process) = archive.read_header()? { archive = if *name == process.entry().filename.as_os_str().to_string_lossy() { let (data, rest) = process.read()?; archive_cache.add_preloaded_data(name.clone(), data); drop(rest); break; } else { process.skip()? }; } file_paths.push(PathSource::Preloaded(path_buf)); } else { // Large archive - use Archive variant for on-demand loading file_paths.push(PathSource::Archive(path_buf)); } } Ok(()) } fn read_7z_path(path: &PathBuf, file_paths: &mut Vec, archive_cache: &mut ArchiveCache, archive_cache_size: u64, archive_warning_threshold_mb: u64) -> Result<(), Box> { use std::thread; use std::io::Read; let password = sevenz_rust2::Password::empty(); let mut file = File::open(path)?; let archive = sevenz_rust2::Archive::read(&mut file, &password)?; let is_solid = archive.is_solid; let mut files = Vec::new(); let mut image_names = Vec::new(); // Set up the archive cache for this 7Z file archive_cache.set_current_archive(path.clone(), ArchiveType::SevenZ); // First pass: collect all image files and their sizes for entry in archive.files.iter() { if !entry.is_directory && supported_image(entry.name()) { files.push(entry.size()); image_names.push(entry.name()); } } let image_size = files.iter().sum::(); debug!("Total image size: {}mb", image_size / 1_000_000); // Determine if we'll preload this archive (small archives get preloaded) let will_preload = is_solid || image_size < archive_cache_size; // Check for large solid archives and show warning dialog if will_preload && image_size > 0 { let archive_size_mb = image_size / 1_000_000; // Show warning dialog for archives larger than configured threshold if archive_size_mb > archive_warning_threshold_mb { let (available_gb, is_recommended) = mem::check_memory_for_archive(archive_size_mb); // Show the warning dialog and check user response if !file_io::show_memory_warning_sync(archive_size_mb, available_gb, is_recommended) { // User chose not to proceed return Err("User cancelled loading large solid 7z archive".into()); } // User chose to proceed, continue with loading } // solid file is too slow for lazy loading - proceed with preload let block_count = archive.blocks.len(); debug!("{path:?} block_count: {block_count}"); let cpu_threads = if thread::available_parallelism().is_ok() { thread::available_parallelism()?.get() as u32 } else { 4 }; debug!("Using {cpu_threads} threads to read {path:?}"); let sevenz_data = Mutex::new(Vec::new()); for block_index in 0..block_count { thread::scope(|s| { s.spawn(||{ let mut source = File::open(path).unwrap(); // 2. For decoders that supports it, we can set the thread_count on the block decoder // so that it uses multiple threads to decode the block. Currently only LZMA2 is // supporting this. We try to use all threads report from std::thread. let block_decoder = sevenz_rust2::BlockDecoder::new(cpu_threads, block_index, &archive, &password, &mut source); block_decoder.for_each_entries(&mut |entry, reader| { let mut buffer = Vec::new(); if !entry.is_directory && supported_image(entry.name()) { reader.read_to_end(&mut buffer)?; sevenz_data.lock().unwrap().push((entry.name().to_string(), buffer)); } else { // As `for_each_entries` noted, we can not skip any files we don't want. // Discard all the bytes we don't need. let _ = std::io::copy(&mut reader.take(entry.size()), &mut std::io::sink()); } Ok(true) }) .expect("Failed block reading 7z file"); }); }); } // Add files and preloaded data to respective structures let data_list = sevenz_data.into_inner()?; for (filename, data) in data_list { let path_buf = PathBuf::from(&filename); // Solid 7z archives are always preloaded - use Preloaded variant file_paths.push(PathSource::Preloaded(path_buf)); archive_cache.add_preloaded_data(filename, data); } } else { // Non-perload 7z: just list files without preloading - use Archive variant for name in &image_names { file_paths.push(PathSource::Archive(PathBuf::from(name))); } } Ok(()) } ================================================ FILE: src/replay.rs ================================================ use std::time::{Duration, Instant}; use std::path::PathBuf; use log::{debug, info, warn}; #[derive(Debug, Clone, PartialEq, Default)] pub enum OutputFormat { #[default] Text, Json, Markdown, } #[derive(Debug, Clone, PartialEq, Default)] pub enum NavigationMode { #[default] Keyboard, Slider, } #[derive(Debug, Clone)] pub struct ReplayConfig { pub test_directories: Vec, pub duration_per_directory: Duration, pub navigation_interval: Duration, pub directions: Vec, pub output_file: Option, pub output_format: OutputFormat, pub verbose: bool, pub iterations: u32, pub auto_exit: bool, /// Number of initial images to skip for metrics (to avoid inflated FPS from cached images) pub skip_initial_images: usize, /// Navigation mode: Keyboard (continuous skating) or Slider (stepped position changes) pub navigation_mode: NavigationMode, /// Step size for slider navigation mode (how many images to skip per navigation) pub slider_step: u16, } #[derive(Debug, Clone, PartialEq)] pub enum ReplayDirection { Right, Left, Both, } #[derive(Debug, Clone, PartialEq)] pub enum ReplayState { Inactive, LoadingDirectory { directory_index: usize }, WaitingForReady { directory_index: usize }, NavigatingRight { start_time: Instant, directory_index: usize }, NavigatingLeft { start_time: Instant, directory_index: usize }, Finished, } #[derive(Debug, Clone)] pub struct ReplayMetrics { pub directory_path: PathBuf, pub direction: ReplayDirection, pub start_time: Instant, pub end_time: Instant, pub total_frames: u32, pub ui_fps_samples: Vec, pub image_fps_samples: Vec, pub memory_samples: Vec, pub min_ui_fps: f32, pub max_ui_fps: f32, pub avg_ui_fps: f32, pub min_image_fps: f32, pub max_image_fps: f32, pub avg_image_fps: f32, pub last_image_fps: f32, pub min_memory_mb: f64, pub max_memory_mb: f64, pub avg_memory_mb: f64, } impl ReplayMetrics { pub fn new(directory_path: PathBuf, direction: ReplayDirection) -> Self { let now = Instant::now(); Self { directory_path, direction, start_time: now, end_time: now, total_frames: 0, ui_fps_samples: Vec::new(), image_fps_samples: Vec::new(), memory_samples: Vec::new(), min_ui_fps: f32::MAX, max_ui_fps: 0.0, avg_ui_fps: 0.0, min_image_fps: f32::MAX, max_image_fps: 0.0, avg_image_fps: 0.0, last_image_fps: 0.0, min_memory_mb: f64::MAX, max_memory_mb: 0.0, avg_memory_mb: 0.0, } } pub fn add_sample(&mut self, ui_fps: f32, image_fps: f32, memory_mb: f64) { self.total_frames += 1; // Collect samples self.ui_fps_samples.push(ui_fps); self.image_fps_samples.push(image_fps); self.memory_samples.push(memory_mb); // Update min/max for UI FPS if ui_fps < self.min_ui_fps { self.min_ui_fps = ui_fps; } if ui_fps > self.max_ui_fps { self.max_ui_fps = ui_fps; } // Update min/max/last for Image FPS if image_fps < self.min_image_fps { self.min_image_fps = image_fps; } if image_fps > self.max_image_fps { self.max_image_fps = image_fps; } self.last_image_fps = image_fps; // Update min/max for Memory (if valid) if memory_mb >= 0.0 { if memory_mb < self.min_memory_mb { self.min_memory_mb = memory_mb; } if memory_mb > self.max_memory_mb { self.max_memory_mb = memory_mb; } } } pub fn finalize(&mut self) { self.end_time = Instant::now(); // Calculate averages if !self.ui_fps_samples.is_empty() { self.avg_ui_fps = self.ui_fps_samples.iter().sum::() / self.ui_fps_samples.len() as f32; } if !self.image_fps_samples.is_empty() { self.avg_image_fps = self.image_fps_samples.iter().sum::() / self.image_fps_samples.len() as f32; } let valid_memory_samples: Vec = self.memory_samples.iter().filter(|&&m| m >= 0.0).cloned().collect(); if !valid_memory_samples.is_empty() { self.avg_memory_mb = valid_memory_samples.iter().sum::() / valid_memory_samples.len() as f64; } // Handle edge case where no valid memory samples exist if self.min_memory_mb == f64::MAX { self.min_memory_mb = -1.0; // Use -1 to indicate N/A } } pub fn duration(&self) -> Duration { self.end_time.duration_since(self.start_time) } pub fn print_summary(&self) { let duration = self.duration(); info!("=== Replay Metrics Summary ==="); info!("Directory: {}", self.directory_path.display()); info!("Direction: {:?}", self.direction); info!("Duration: {:.2}s", duration.as_secs_f64()); info!("Total Frames: {}", self.total_frames); info!("UI FPS - Min: {:.1}, Max: {:.1}, Avg: {:.1}", self.min_ui_fps, self.max_ui_fps, self.avg_ui_fps); info!("Image FPS - Min: {:.1}, Max: {:.1}, Avg: {:.1}", self.min_image_fps, self.max_image_fps, self.avg_image_fps); if self.min_memory_mb >= 0.0 { info!("Memory (MB) - Min: {:.1}, Max: {:.1}, Avg: {:.1}", self.min_memory_mb, self.max_memory_mb, self.avg_memory_mb); } else { info!("Memory: N/A"); } if self.avg_ui_fps < 30.0 { warn!("UI FPS below 30 - potential performance issue"); } if self.avg_image_fps < 30.0 { warn!("Image FPS below 30 - potential rendering bottleneck"); } } } pub struct ReplayController { pub config: ReplayConfig, pub state: ReplayState, pub current_metrics: Option, pub completed_metrics: Vec, pub last_navigation_time: Instant, pub current_iteration: u32, pub completed_iterations: u32, /// True when navigation has reached the boundary (end or beginning of images) /// Metrics are not collected while at boundary to avoid skewing results pub at_boundary: bool, /// Count of navigations in current direction (reset when direction/directory changes) pub navigation_count: usize, /// Current slider position for slider navigation mode (0-indexed) pub current_slider_position: u16, /// Maximum slider position (image_count - 1, set when directory loads) pub max_slider_position: u16, } impl ReplayController { pub fn new(config: ReplayConfig) -> Self { Self { config, state: ReplayState::Inactive, current_metrics: None, completed_metrics: Vec::new(), last_navigation_time: Instant::now(), current_iteration: 0, completed_iterations: 0, at_boundary: false, navigation_count: 0, current_slider_position: 0, max_slider_position: 0, } } pub fn start(&mut self) { if !self.config.test_directories.is_empty() && self.completed_iterations < self.config.iterations { self.current_iteration += 1; info!("Starting replay mode iteration {}/{} with {} directories", self.current_iteration, self.config.iterations, self.config.test_directories.len()); self.state = ReplayState::LoadingDirectory { directory_index: 0 }; } else { if self.completed_iterations >= self.config.iterations { info!("All {} iterations completed", self.config.iterations); } else { warn!("No test directories configured for replay mode"); } self.state = ReplayState::Finished; } } pub fn is_active(&self) -> bool { !matches!(self.state, ReplayState::Inactive | ReplayState::Finished) } pub fn is_completed(&self) -> bool { matches!(self.state, ReplayState::Finished) && self.completed_iterations >= self.config.iterations } pub fn get_current_directory(&self) -> Option<&PathBuf> { match &self.state { ReplayState::LoadingDirectory { directory_index } | ReplayState::WaitingForReady { directory_index } | ReplayState::NavigatingRight { directory_index, .. } | ReplayState::NavigatingLeft { directory_index, .. } => { self.config.test_directories.get(*directory_index) } _ => None, } } pub fn on_navigation_performed(&mut self) { self.last_navigation_time = Instant::now(); self.navigation_count += 1; } /// Called by app when navigation reaches a boundary (end or beginning of images) /// This prevents collecting misleading metrics while stuck at boundary pub fn set_at_boundary(&mut self, at_boundary: bool) { if self.at_boundary != at_boundary { if at_boundary { debug!("Navigation reached boundary - metrics collection paused"); } else { debug!("Navigation resumed from boundary"); } self.at_boundary = at_boundary; } } pub fn on_directory_loaded(&mut self, directory_index: usize) { if let ReplayState::LoadingDirectory { directory_index: expected_index } = &self.state { if *expected_index == directory_index { let directory_path = self.config.test_directories[directory_index].clone(); info!("Directory loaded for replay: {}, waiting for app to be ready...", directory_path.display()); // Transition to waiting state instead of immediately starting navigation self.state = ReplayState::WaitingForReady { directory_index }; } } } pub fn on_ready_to_navigate(&mut self) { if let ReplayState::WaitingForReady { directory_index } = &self.state { let directory_index = *directory_index; let directory_path = self.config.test_directories[directory_index].clone(); // Reset navigation count for new directory self.navigation_count = 0; // Start with the first direction for this directory let direction = self.config.directions.first().unwrap_or(&ReplayDirection::Right); match direction { ReplayDirection::Left => { self.state = ReplayState::NavigatingLeft { start_time: Instant::now(), directory_index }; self.current_metrics = Some(ReplayMetrics::new(directory_path.clone(), ReplayDirection::Left)); } ReplayDirection::Right | ReplayDirection::Both => { // Both starts with right navigation first self.state = ReplayState::NavigatingRight { start_time: Instant::now(), directory_index }; self.current_metrics = Some(ReplayMetrics::new(directory_path.clone(), ReplayDirection::Right)); } } info!("App ready - started replay navigation for directory: {}", directory_path.display()); } } /// Set the total image count for slider navigation mode /// Called after directory loads to enable proper slider position tracking pub fn set_image_count(&mut self, count: usize) { self.max_slider_position = count.saturating_sub(1) as u16; // Reset position to start for right navigation, or end for left-only navigation let direction = self.config.directions.first().unwrap_or(&ReplayDirection::Right); if matches!(direction, ReplayDirection::Left) { self.current_slider_position = self.max_slider_position; } else { self.current_slider_position = 0; } debug!("Slider position initialized: current={}, max={}", self.current_slider_position, self.max_slider_position); } pub fn update_metrics(&mut self, ui_fps: f32, image_fps: f32, memory_mb: f64) { // Skip metric collection when at boundary to avoid skewing results // (FPS during idle time isn't representative of navigation performance) if self.at_boundary { return; } // Skip initial images that are pre-cached (would show inflated FPS) if self.navigation_count < self.config.skip_initial_images { return; } if let Some(ref mut metrics) = self.current_metrics { metrics.add_sample(ui_fps, image_fps, memory_mb); } } /// Finalize current metrics and prepare for next phase fn finalize_current_metrics(&mut self) { if let Some(mut metrics) = self.current_metrics.take() { metrics.finalize(); if self.config.verbose { metrics.print_summary(); } self.completed_metrics.push(metrics); } self.at_boundary = false; } /// Check if navigation phase should transition (duration elapsed or boundary reached) fn should_transition(&self, elapsed: Duration) -> bool { const MIN_METRICS_DURATION: Duration = Duration::from_secs(1); elapsed >= self.config.duration_per_directory || (self.at_boundary && elapsed >= MIN_METRICS_DURATION) } pub fn update(&mut self) -> Option { match &self.state { ReplayState::NavigatingRight { start_time, directory_index } => { let elapsed = start_time.elapsed(); let directory_index = *directory_index; if self.should_transition(elapsed) { if self.at_boundary && elapsed < self.config.duration_per_directory { info!("Early transition: reached end of images after {:.2}s (duration was {:.2}s)", elapsed.as_secs_f64(), self.config.duration_per_directory.as_secs_f64()); } self.finalize_current_metrics(); // Check if we need to test left navigation for this directory if self.config.directions.contains(&ReplayDirection::Both) { let directory_path = self.config.test_directories[directory_index].clone(); info!("Switching from right to left navigation for directory: {}", directory_path.display()); self.navigation_count = 0; self.state = ReplayState::NavigatingLeft { start_time: Instant::now(), directory_index }; self.current_metrics = Some(ReplayMetrics::new(directory_path, ReplayDirection::Left)); // In slider mode, reset position to max for left navigation if self.config.navigation_mode == NavigationMode::Slider { self.current_slider_position = self.max_slider_position; Some(ReplayAction::SliderStartNavigatingLeft) } else { Some(ReplayAction::StartNavigatingLeft) } } else { self.advance_to_next_directory(directory_index) } } else if self.last_navigation_time.elapsed() >= self.config.navigation_interval { // Navigation action based on mode if self.config.navigation_mode == NavigationMode::Slider { self.navigate_slider_right() } else { Some(ReplayAction::NavigateRight) } } else { None } } ReplayState::NavigatingLeft { start_time, directory_index } => { let elapsed = start_time.elapsed(); let directory_index = *directory_index; if self.should_transition(elapsed) { if self.at_boundary && elapsed < self.config.duration_per_directory { info!("Early transition: reached beginning of images after {:.2}s (duration was {:.2}s)", elapsed.as_secs_f64(), self.config.duration_per_directory.as_secs_f64()); } self.finalize_current_metrics(); self.advance_to_next_directory(directory_index) } else if self.last_navigation_time.elapsed() >= self.config.navigation_interval { // Navigation action based on mode if self.config.navigation_mode == NavigationMode::Slider { self.navigate_slider_left() } else { Some(ReplayAction::NavigateLeft) } } else { None } } ReplayState::WaitingForReady { .. } => None, _ => None, } } /// Navigate right in slider mode: increment position and emit SliderNavigate action fn navigate_slider_right(&mut self) -> Option { if self.current_slider_position >= self.max_slider_position { // Already at end self.set_at_boundary(true); return None; } // Increment position by step, clamping to max let new_pos = (self.current_slider_position + self.config.slider_step) .min(self.max_slider_position); self.current_slider_position = new_pos; self.on_navigation_performed(); debug!("Slider navigate right: position {} / {}", new_pos, self.max_slider_position); Some(ReplayAction::SliderNavigate { position: new_pos }) } /// Navigate left in slider mode: decrement position and emit SliderNavigate action fn navigate_slider_left(&mut self) -> Option { if self.current_slider_position == 0 { // Already at beginning self.set_at_boundary(true); return None; } // Decrement position by step, clamping to 0 let new_pos = self.current_slider_position.saturating_sub(self.config.slider_step); self.current_slider_position = new_pos; self.on_navigation_performed(); debug!("Slider navigate left: position {} / {}", new_pos, self.max_slider_position); Some(ReplayAction::SliderNavigate { position: new_pos }) } fn advance_to_next_directory(&mut self, current_directory_index: usize) -> Option { let next_index = current_directory_index + 1; if next_index < self.config.test_directories.len() { // Still have more directories in this iteration self.state = ReplayState::LoadingDirectory { directory_index: next_index }; Some(ReplayAction::LoadDirectory(self.config.test_directories[next_index].clone())) } else { // Completed all directories in this iteration self.completed_iterations += 1; info!("Completed iteration {}/{}", self.completed_iterations, self.config.iterations); if self.completed_iterations < self.config.iterations { // Start next iteration - use RestartIteration for same directory to avoid reload delay info!("Starting next iteration..."); debug!("Transitioning from completed iteration {} to iteration {}", self.completed_iterations, self.completed_iterations + 1); self.current_iteration += 1; self.state = ReplayState::LoadingDirectory { directory_index: 0 }; debug!("Set state to LoadingDirectory, returning RestartIteration action"); Some(ReplayAction::RestartIteration(self.config.test_directories[0].clone())) } else { // All iterations completed self.state = ReplayState::Finished; info!("Replay mode completed! All {} iterations finished.", self.config.iterations); self.print_final_summary(); Some(ReplayAction::Finish) } } } pub fn print_final_summary(&self) { info!("=== FINAL REPLAY SUMMARY ==="); info!("Total directories tested: {}", self.completed_metrics.len()); if !self.completed_metrics.is_empty() { let total_ui_fps: f32 = self.completed_metrics.iter().map(|m| m.avg_ui_fps).sum(); let total_image_fps: f32 = self.completed_metrics.iter().map(|m| m.avg_image_fps).sum(); let count = self.completed_metrics.len() as f32; info!("Overall Average UI FPS: {:.1}", total_ui_fps / count); info!("Overall Average Image FPS: {:.1}", total_image_fps / count); let min_ui_fps = self.completed_metrics.iter().map(|m| m.min_ui_fps).fold(f32::INFINITY, f32::min); let max_ui_fps = self.completed_metrics.iter().map(|m| m.max_ui_fps).fold(0.0, f32::max); info!("UI FPS Range: {:.1} - {:.1}", min_ui_fps, max_ui_fps); // Export to file if requested if let Some(ref output_file) = self.config.output_file { if let Err(e) = self.export_metrics_to_file(output_file) { warn!("Failed to export metrics to file: {}", e); } else { info!("Metrics exported to: {}", output_file.display()); } } } } fn export_metrics_to_file(&self, output_file: &PathBuf) -> Result<(), std::io::Error> { use std::fs::File; let mut file = File::create(output_file)?; match self.config.output_format { OutputFormat::Json => self.export_json(&mut file), OutputFormat::Markdown => self.export_markdown(&mut file), OutputFormat::Text => self.export_text(&mut file), } } fn export_text(&self, file: &mut std::fs::File) -> Result<(), std::io::Error> { use std::io::Write; writeln!(file, "ViewSkater Replay Benchmark Results")?; writeln!(file, "Generated: {:?}", std::time::SystemTime::now())?; writeln!(file)?; for (i, metrics) in self.completed_metrics.iter().enumerate() { writeln!(file, "Test {}: {}", i + 1, metrics.directory_path.display())?; writeln!(file, "Direction: {:?}", metrics.direction)?; writeln!(file, "Duration: {:.2}s", metrics.duration().as_secs_f64())?; writeln!(file, "Total Frames: {}", metrics.total_frames)?; writeln!(file, "UI FPS - Min: {:.1}, Max: {:.1}, Avg: {:.1}", metrics.min_ui_fps, metrics.max_ui_fps, metrics.avg_ui_fps)?; writeln!(file, "Image FPS - Min: {:.1}, Max: {:.1}, Avg: {:.1}", metrics.min_image_fps, metrics.max_image_fps, metrics.avg_image_fps)?; if metrics.min_memory_mb >= 0.0 { writeln!(file, "Memory (MB) - Min: {:.1}, Max: {:.1}, Avg: {:.1}", metrics.min_memory_mb, metrics.max_memory_mb, metrics.avg_memory_mb)?; } else { writeln!(file, "Memory: N/A")?; } writeln!(file)?; } Ok(()) } fn export_json(&self, file: &mut std::fs::File) -> Result<(), std::io::Error> { use std::io::Write; let results: Vec = self.completed_metrics.iter().map(|m| { serde_json::json!({ "directory": m.directory_path.to_string_lossy(), "direction": format!("{:?}", m.direction), "duration_secs": m.duration().as_secs_f64(), "total_frames": m.total_frames, "ui_fps": { "min": m.min_ui_fps, "max": m.max_ui_fps, "avg": m.avg_ui_fps }, "image_fps": { "min": m.min_image_fps, "max": m.max_image_fps, "avg": m.avg_image_fps, "last": m.last_image_fps }, "memory_mb": if m.min_memory_mb >= 0.0 { serde_json::json!({ "min": m.min_memory_mb, "max": m.max_memory_mb, "avg": m.avg_memory_mb }) } else { serde_json::Value::Null } }) }).collect(); let output = serde_json::json!({ "generated": chrono::Utc::now().to_rfc3339(), "iterations": self.config.iterations, "results": results }); writeln!(file, "{}", serde_json::to_string_pretty(&output).unwrap_or_default())?; Ok(()) } fn export_markdown(&self, file: &mut std::fs::File) -> Result<(), std::io::Error> { use std::io::Write; writeln!(file, "# ViewSkater Replay Benchmark Results")?; writeln!(file)?; writeln!(file, "Generated: {}", chrono::Utc::now().to_rfc3339())?; writeln!(file)?; writeln!(file, "| Directory | Direction | Duration | Frames | UI FPS (avg) | Image FPS (avg) | Image FPS (last) | Memory (avg) |")?; writeln!(file, "|-----------|-----------|----------|--------|--------------|-----------------|------------------|--------------|")?; for metrics in &self.completed_metrics { let dir_name = metrics.directory_path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| metrics.directory_path.to_string_lossy().to_string()); let memory = if metrics.min_memory_mb >= 0.0 { format!("{:.1} MB", metrics.avg_memory_mb) } else { "N/A".to_string() }; writeln!(file, "| {} | {:?} | {:.2}s | {} | {:.1} | {:.1} | {:.1} | {} |", dir_name, metrics.direction, metrics.duration().as_secs_f64(), metrics.total_frames, metrics.avg_ui_fps, metrics.avg_image_fps, metrics.last_image_fps, memory)?; } writeln!(file)?; // Add summary stats if !self.completed_metrics.is_empty() { let avg_ui_fps: f32 = self.completed_metrics.iter().map(|m| m.avg_ui_fps).sum::() / self.completed_metrics.len() as f32; let avg_image_fps: f32 = self.completed_metrics.iter().map(|m| m.avg_image_fps).sum::() / self.completed_metrics.len() as f32; writeln!(file, "## Summary")?; writeln!(file)?; writeln!(file, "- **Overall Avg UI FPS:** {:.1}", avg_ui_fps)?; writeln!(file, "- **Overall Avg Image FPS:** {:.1}", avg_image_fps)?; } Ok(()) } } #[derive(Debug, Clone)] pub enum ReplayAction { LoadDirectory(PathBuf), RestartIteration(PathBuf), // Start new iteration from first directory (reloads to reset position) NavigateRight, NavigateLeft, StartNavigatingLeft, /// Slider navigation: move to specific position (used in slider mode) SliderNavigate { position: u16 }, /// Switch to left navigation in slider mode (resets position to max) SliderStartNavigatingLeft, Finish, } ================================================ FILE: src/selection_manager.rs ================================================ use std::collections::{HashMap, hash_map::DefaultHasher}; use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::time::SystemTime; use serde::{Deserialize, Serialize}; #[allow(unused_imports)] use log::{debug, error, info, warn}; /// Represents the marking state of an image #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum ImageMark { #[default] Unmarked, Selected, // Will be included in "copy selected" export Excluded, // Will be excluded in "copy non-excluded" export } /// Stores selection state for a directory #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SelectionState { pub directory_path: String, pub marks: HashMap, #[serde(skip, default = "SystemTime::now")] pub last_modified: SystemTime, #[serde(skip, default)] pub dirty: bool, // Flag to track if we need to save } impl SelectionState { pub fn new(directory_path: String) -> Self { Self { directory_path, marks: HashMap::new(), last_modified: SystemTime::now(), dirty: false, } } /// Mark an image with a specific state pub fn mark_image(&mut self, filename: &str, mark: ImageMark) { self.marks.insert(filename.to_string(), mark); self.last_modified = SystemTime::now(); self.dirty = true; debug!("Marked {} as {:?}", filename, mark); } /// Get the mark for an image (returns Unmarked if not found) pub fn get_mark(&self, filename: &str) -> ImageMark { self.marks.get(filename).copied().unwrap_or(ImageMark::Unmarked) } /// Toggle selection state for an image pub fn toggle_selected(&mut self, filename: &str) { let current = self.get_mark(filename); let new_mark = if current == ImageMark::Selected { ImageMark::Unmarked } else { ImageMark::Selected }; self.mark_image(filename, new_mark); } /// Toggle exclusion state for an image pub fn toggle_excluded(&mut self, filename: &str) { let current = self.get_mark(filename); let new_mark = if current == ImageMark::Excluded { ImageMark::Unmarked } else { ImageMark::Excluded }; self.mark_image(filename, new_mark); } /// Clear the mark for an image pub fn clear_mark(&mut self, filename: &str) { if self.marks.remove(filename).is_some() { self.last_modified = SystemTime::now(); self.dirty = true; debug!("Cleared mark for {}", filename); } } /// Get count of selected images #[allow(dead_code)] pub fn selected_count(&self) -> usize { self.marks.values().filter(|&&m| m == ImageMark::Selected).count() } /// Get count of excluded images #[allow(dead_code)] pub fn excluded_count(&self) -> usize { self.marks.values().filter(|&&m| m == ImageMark::Excluded).count() } /// Get count of marked images (selected or excluded) #[allow(dead_code)] pub fn marked_count(&self) -> usize { self.marks.len() } } /// Manages selection states across multiple directories pub struct SelectionManager { current_state: Option, data_dir: PathBuf, } impl SelectionManager { pub fn new() -> Self { let data_dir = Self::get_selections_dir(); // Create the directory if it doesn't exist if let Err(e) = std::fs::create_dir_all(&data_dir) { error!("Failed to create selections directory: {}", e); } else { info!("Selection data directory: {}", data_dir.display()); } Self { current_state: None, data_dir, } } /// Get the platform-specific directory for storing selection data fn get_selections_dir() -> PathBuf { let data_dir = dirs::data_local_dir() .unwrap_or_else(|| PathBuf::from(".")); data_dir.join("viewskater").join("selections") } /// Generate a hash-based filename for a directory path fn get_selection_file_path(&self, dir_path: &str) -> PathBuf { let mut hasher = DefaultHasher::new(); dir_path.hash(&mut hasher); let hash = hasher.finish(); self.data_dir.join(format!("{:x}.json", hash)) } /// Load selection state for a directory pub fn load_for_directory(&mut self, dir_path: &str) -> Result<(), std::io::Error> { let file_path = self.get_selection_file_path(dir_path); if !file_path.exists() { debug!("No existing selection file for directory: {}", dir_path); self.current_state = Some(SelectionState::new(dir_path.to_string())); return Ok(()); } match std::fs::read_to_string(&file_path) { Ok(json_str) => { match serde_json::from_str::(&json_str) { Ok(mut state) => { state.dirty = false; state.last_modified = SystemTime::now(); info!("Loaded {} marks for directory: {}", state.marks.len(), dir_path); self.current_state = Some(state); Ok(()) } Err(e) => { error!("Failed to parse selection file: {}", e); self.current_state = Some(SelectionState::new(dir_path.to_string())); Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } } } Err(e) => { error!("Failed to read selection file: {}", e); self.current_state = Some(SelectionState::new(dir_path.to_string())); Err(e) } } } /// Save the current selection state to disk pub fn save(&mut self) -> Result<(), std::io::Error> { if let Some(ref state) = self.current_state { if !state.dirty { debug!("Selection state not dirty, skipping save"); return Ok(()); } let file_path = self.get_selection_file_path(&state.directory_path); match serde_json::to_string_pretty(state) { Ok(json_str) => { match std::fs::write(&file_path, json_str) { Ok(_) => { info!("Saved selection state to: {}", file_path.display()); // Mark as clean after successful save if let Some(ref mut s) = self.current_state { s.dirty = false; } Ok(()) } Err(e) => { error!("Failed to write selection file: {}", e); Err(e) } } } Err(e) => { error!("Failed to serialize selection state: {}", e); Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) } } } else { debug!("No current selection state to save"); Ok(()) } } /// Export the current selection state to a specific JSON file pub fn export_to_file(&self, export_path: &Path) -> Result<(), std::io::Error> { if let Some(ref state) = self.current_state { let json_str = serde_json::to_string_pretty(state) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; std::fs::write(export_path, json_str)?; info!("Exported selection state to: {}", export_path.display()); Ok(()) } else { Err(std::io::Error::new( std::io::ErrorKind::NotFound, "No selection state to export" )) } } /// Get a mutable reference to the current state #[allow(dead_code)] pub fn current_state_mut(&mut self) -> Option<&mut SelectionState> { self.current_state.as_mut() } /// Get a reference to the current state #[allow(dead_code)] pub fn current_state(&self) -> Option<&SelectionState> { self.current_state.as_ref() } /// Mark an image in the current directory #[allow(dead_code)] pub fn mark_image(&mut self, filename: &str, mark: ImageMark) { if let Some(ref mut state) = self.current_state { state.mark_image(filename, mark); } } /// Toggle selected state for an image pub fn toggle_selected(&mut self, filename: &str) { if let Some(ref mut state) = self.current_state { state.toggle_selected(filename); } } /// Toggle excluded state for an image pub fn toggle_excluded(&mut self, filename: &str) { if let Some(ref mut state) = self.current_state { state.toggle_excluded(filename); } } /// Clear mark for an image pub fn clear_mark(&mut self, filename: &str) { if let Some(ref mut state) = self.current_state { state.clear_mark(filename); } } /// Get the mark for an image pub fn get_mark(&self, filename: &str) -> ImageMark { self.current_state .as_ref() .map(|s| s.get_mark(filename)) .unwrap_or(ImageMark::Unmarked) } } impl Default for SelectionManager { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_selection_state() { let mut state = SelectionState::new("/test/path".to_string()); // Test marking state.mark_image("test.jpg", ImageMark::Selected); assert_eq!(state.get_mark("test.jpg"), ImageMark::Selected); assert_eq!(state.selected_count(), 1); // Test toggling state.toggle_excluded("test2.jpg"); assert_eq!(state.get_mark("test2.jpg"), ImageMark::Excluded); assert_eq!(state.excluded_count(), 1); // Test clearing state.clear_mark("test.jpg"); assert_eq!(state.get_mark("test.jpg"), ImageMark::Unmarked); } } ================================================ FILE: src/settings.rs ================================================ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use log::{debug, info, warn, error}; use iced_wgpu::engine::CompressionStrategy; use crate::cache::img_cache::CacheStrategy; use crate::config; /// User-specific settings that persist across app sessions #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserSettings { /// Toggle display of FPS counter #[serde(default)] pub show_fps: bool, /// Toggle footer visibility #[serde(default = "default_show_footer")] pub show_footer: bool, /// Use horizontal split for dual panes #[serde(default)] pub is_horizontal_split: bool, /// Sync zoom and pan between panes #[serde(default = "default_synced_zoom")] pub synced_zoom: bool, /// Enable mouse wheel zoom #[serde(default)] pub mouse_wheel_zoom: bool, /// Cache strategy: "cpu" or "gpu" #[serde(default = "default_cache_strategy")] pub cache_strategy: String, /// Compression strategy: "none" or "bc1" #[serde(default = "default_compression_strategy")] pub compression_strategy: String, /// Slider type: dual (true) or single (false) #[serde(default)] pub is_slider_dual: bool, /// Show copy filename/filepath buttons in footer #[serde(default = "default_show_copy_buttons")] pub show_copy_buttons: bool, /// Show image metadata (resolution, file size) in footer #[serde(default = "default_show_metadata")] pub show_metadata: bool, /// Use nearest-neighbor filtering for pixel-perfect image scaling #[serde(default)] pub nearest_neighbor_filter: bool, // Advanced settings (from config.rs) /// Cache window size #[serde(default = "default_cache_size")] pub cache_size: usize, /// Max size for the loading queue #[serde(default = "default_max_loading_queue_size")] pub max_loading_queue_size: usize, /// Max size for being loaded queue #[serde(default = "default_max_being_loaded_queue_size")] pub max_being_loaded_queue_size: usize, /// Default window width #[serde(default = "default_window_width")] pub window_width: u32, /// Default window height #[serde(default = "default_window_height")] pub window_height: u32, /// Texture atlas size (affects slider performance) #[serde(default = "default_atlas_size")] pub atlas_size: u32, /// Double-click detection threshold in milliseconds #[serde(default = "default_double_click_threshold_ms")] pub double_click_threshold_ms: u16, /// Max size for compressed file cache (MB) #[serde(default = "default_archive_cache_size")] pub archive_cache_size: u64, /// Warning threshold for solid archives (MB) #[serde(default = "default_archive_warning_threshold_mb")] pub archive_warning_threshold_mb: u64, /// COCO: Disable polygon simplification for segmentation masks #[serde(default)] pub coco_disable_simplification: bool, /// COCO: Mask rendering mode #[serde(default)] pub coco_mask_render_mode: CocoMaskRenderMode, /// Use binary file size units (KiB/MiB with 1024 divisor) instead of decimal (KB/MB with 1000) /// - true: Binary units like `ls -lh` (1 KiB = 1024 bytes) /// - false: Decimal units like GNOME/macOS/Windows (1 KB = 1000 bytes) #[serde(default)] pub use_binary_size: bool, /// Location where loading spinner is displayed #[serde(default)] pub spinner_location: SpinnerLocation, // Window position and state #[serde(default)] pub window_position_x: i32, #[serde(default)] pub window_position_y: i32, #[serde(default)] pub window_state: WindowState, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CocoMaskRenderMode { /// Polygon-based rendering (vector, scalable) Polygon, /// Pixel-based rendering (raster, exact) Pixel, } impl Default for CocoMaskRenderMode { fn default() -> Self { Self::Polygon // Keep existing behavior as default } } /// Location where the loading spinner is displayed #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SpinnerLocation { /// Show spinner in the footer (default) Footer, /// Show spinner in the menu bar (overlays in fullscreen mode) MenuBar, /// Don't show spinner None, } impl Default for SpinnerLocation { fn default() -> Self { Self::Footer } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum WindowState { #[default] Window, Maximized, FullScreen, } fn default_show_footer() -> bool { true } fn default_synced_zoom() -> bool { true } fn default_cache_strategy() -> String { "gpu".to_string() } fn default_compression_strategy() -> String { "none".to_string() } fn default_show_copy_buttons() -> bool { true } fn default_show_metadata() -> bool { true } // Default functions for advanced settings (using config.rs constants) fn default_cache_size() -> usize { config::DEFAULT_CACHE_SIZE } fn default_max_loading_queue_size() -> usize { config::DEFAULT_MAX_LOADING_QUEUE_SIZE } fn default_max_being_loaded_queue_size() -> usize { config::DEFAULT_MAX_BEING_LOADED_QUEUE_SIZE } fn default_window_width() -> u32 { config::DEFAULT_WINDOW_WIDTH } fn default_window_height() -> u32 { config::DEFAULT_WINDOW_HEIGHT } fn default_atlas_size() -> u32 { config::DEFAULT_ATLAS_SIZE } fn default_double_click_threshold_ms() -> u16 { config::DEFAULT_DOUBLE_CLICK_THRESHOLD_MS } fn default_archive_cache_size() -> u64 { config::DEFAULT_ARCHIVE_CACHE_SIZE } fn default_archive_warning_threshold_mb() -> u64 { config::DEFAULT_ARCHIVE_WARNING_THRESHOLD_MB } impl Default for UserSettings { fn default() -> Self { Self { show_fps: false, show_footer: true, is_horizontal_split: false, synced_zoom: true, mouse_wheel_zoom: false, cache_strategy: "gpu".to_string(), compression_strategy: "none".to_string(), is_slider_dual: false, show_copy_buttons: true, show_metadata: true, nearest_neighbor_filter: false, cache_size: config::DEFAULT_CACHE_SIZE, max_loading_queue_size: config::DEFAULT_MAX_LOADING_QUEUE_SIZE, max_being_loaded_queue_size: config::DEFAULT_MAX_BEING_LOADED_QUEUE_SIZE, window_width: config::DEFAULT_WINDOW_WIDTH, window_height: config::DEFAULT_WINDOW_HEIGHT, atlas_size: config::DEFAULT_ATLAS_SIZE, double_click_threshold_ms: config::DEFAULT_DOUBLE_CLICK_THRESHOLD_MS, archive_cache_size: config::DEFAULT_ARCHIVE_CACHE_SIZE, archive_warning_threshold_mb: config::DEFAULT_ARCHIVE_WARNING_THRESHOLD_MB, coco_disable_simplification: false, coco_mask_render_mode: CocoMaskRenderMode::default(), use_binary_size: false, // Default to decimal (GNOME/macOS/Windows style) spinner_location: SpinnerLocation::default(), window_position_x: 0, window_position_y: 0, window_state: WindowState::Window, } } } impl UserSettings { /// Get the path to the settings file /// On macOS: ~/Library/Application Support/viewskater/settings.yaml /// On Linux: ~/.config/viewskater/settings.yaml /// On Windows: C:\Users\\AppData\Roaming\viewskater\settings.yaml pub fn settings_path() -> PathBuf { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")); let app_config_dir = config_dir.join("viewskater"); app_config_dir.join("settings.yaml") } /// Load settings from the YAML file /// If custom_path is provided, uses that path; otherwise uses the default settings path pub fn load(custom_path: Option<&str>) -> Self { let path = match custom_path { Some(p) => { info!("Using custom settings path: {}", p); PathBuf::from(p) } None => Self::settings_path(), }; if !path.exists() { info!("Settings file not found at {:?}, using defaults", path); return Self::default(); } match fs::read_to_string(&path) { Ok(contents) => { match serde_yaml::from_str::(&contents) { Ok(settings) => { info!("Loaded settings from {:?}", path); debug!("Settings: show_fps={}, compression={}, cache={}, mouse_wheel_zoom={}, nearest_neighbor_filter={}", settings.show_fps, settings.compression_strategy, settings.cache_strategy, settings.mouse_wheel_zoom, settings.nearest_neighbor_filter); settings } Err(e) => { error!("Failed to parse settings file at {:?}: {}", path, e); warn!("Using default settings"); Self::default() } } } Err(e) => { error!("Failed to read settings file at {:?}: {}", path, e); warn!("Using default settings"); Self::default() } } } /// Save settings to the YAML file while preserving comments #[allow(dead_code)] pub fn save(&self) -> Result<(), String> { let path = Self::settings_path(); // Create parent directory if it doesn't exist if let Some(parent) = path.parent() { if !parent.exists() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create settings directory: {}", e))?; } } // If file exists, try to preserve comments by doing in-place value updates if path.exists() { match fs::read_to_string(&path) { Ok(contents) => { let updated = self.update_yaml_values(&contents); fs::write(&path, updated) .map_err(|e| format!("Failed to write settings file: {}", e))?; info!("Saved settings to {:?} (comments preserved)", path); return Ok(()); } Err(e) => { warn!("Failed to read existing settings file for comment preservation: {}", e); // Fall through to create new file } } } // File doesn't exist or couldn't be read, create with comments let yaml = self.to_yaml_with_comments(); fs::write(&path, yaml) .map_err(|e| format!("Failed to write settings file: {}", e))?; info!("Saved settings to {:?}", path); Ok(()) } /// Update YAML values while preserving existing comments and structure fn update_yaml_values(&self, yaml_content: &str) -> String { let mut result = yaml_content.to_string(); // Track which keys were found/updated let mut missing_keys = Vec::new(); // Update each field using regex to replace the value while keeping comments result = Self::replace_yaml_value_or_track(&result, "show_fps", &self.show_fps.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "show_footer", &self.show_footer.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "is_horizontal_split", &self.is_horizontal_split.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "synced_zoom", &self.synced_zoom.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "mouse_wheel_zoom", &self.mouse_wheel_zoom.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "cache_strategy", &format!("\"{}\"", self.cache_strategy), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "compression_strategy", &format!("\"{}\"", self.compression_strategy), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "is_slider_dual", &self.is_slider_dual.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "show_copy_buttons", &self.show_copy_buttons.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "show_metadata", &self.show_metadata.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "nearest_neighbor_filter", &self.nearest_neighbor_filter.to_string(), &mut missing_keys); // Update advanced settings result = Self::replace_yaml_value_or_track(&result, "cache_size", &self.cache_size.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "max_loading_queue_size", &self.max_loading_queue_size.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "max_being_loaded_queue_size", &self.max_being_loaded_queue_size.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "window_width", &self.window_width.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "window_height", &self.window_height.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "atlas_size", &self.atlas_size.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "double_click_threshold_ms", &self.double_click_threshold_ms.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "archive_cache_size", &self.archive_cache_size.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "archive_warning_threshold_mb", &self.archive_warning_threshold_mb.to_string(), &mut missing_keys); // Update COCO settings result = Self::replace_yaml_value_or_track(&result, "coco_disable_simplification", &self.coco_disable_simplification.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "coco_mask_render_mode", &format!("\"{}\"", match self.coco_mask_render_mode { CocoMaskRenderMode::Polygon => "Polygon", CocoMaskRenderMode::Pixel => "Pixel", }), &mut missing_keys); // Update display settings result = Self::replace_yaml_value_or_track(&result, "use_binary_size", &self.use_binary_size.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "spinner_location", &format!("\"{}\"", match self.spinner_location { SpinnerLocation::Footer => "Footer", SpinnerLocation::MenuBar => "MenuBar", SpinnerLocation::None => "None", }), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "window_position_x", &self.window_position_x.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "window_position_y", &self.window_position_y.to_string(), &mut missing_keys); result = Self::replace_yaml_value_or_track(&result, "window_state", &format!("\"{}\"", match self.window_state { WindowState::Window => "Window", WindowState::Maximized => "Maximized", WindowState::FullScreen => "FullScreen", }), &mut missing_keys); // Append missing keys with comments if !missing_keys.is_empty() { // Check if we need to add the advanced settings header let needs_header = missing_keys.iter().any(|k| { matches!(k.0.as_str(), "cache_size" | "max_loading_queue_size" | "max_being_loaded_queue_size" | "window_width" | "window_height" | "atlas_size" | "double_click_threshold_ms" | "archive_cache_size" | "archive_warning_threshold_mb") }); if needs_header && !result.contains("# --- Advanced Settings ---") { result.push_str("\n# --- Advanced Settings ---\n"); } for (key, value) in missing_keys { result.push('\n'); // Add comment for the key let comment = Self::get_comment_for_key(&key); if !comment.is_empty() { result.push_str(&comment); result.push('\n'); } result.push_str(&format!("{}: {}\n", key, value)); } } result } /// Get descriptive comment for a settings key fn get_comment_for_key(key: &str) -> String { match key { "cache_size" => "# Cache window size (number of images to keep in cache)".to_string(), "max_loading_queue_size" => "# Max size for loading queue".to_string(), "max_being_loaded_queue_size" => "# Max size for being loaded queue".to_string(), "window_width" => "# Default window width (pixels)".to_string(), "window_height" => "# Default window height (pixels)".to_string(), "atlas_size" => "# Texture atlas size (affects slider performance, power of 2)".to_string(), "double_click_threshold_ms" => "# Double-click detection threshold (milliseconds)".to_string(), "archive_cache_size" => "# Max size for compressed file cache (bytes)".to_string(), "archive_warning_threshold_mb" => "# Warning threshold for solid archives (megabytes)".to_string(), "coco_disable_simplification" => "# COCO: Disable polygon simplification (more accurate but slower)".to_string(), "coco_mask_render_mode" => "# COCO: Mask rendering mode (Polygon or Pixel)".to_string(), "use_binary_size" => "# Use binary file size units (true = KiB/MiB like ls -lh, false = KB/MB like GNOME)".to_string(), "show_metadata" => "# Show image metadata (resolution, file size) in footer".to_string(), "spinner_location" => "# Loading spinner location: Footer, MenuBar, or None".to_string(), _ => String::new(), } } /// Replace a YAML key's value, or track it as missing if not found fn replace_yaml_value_or_track(yaml: &str, key: &str, new_value: &str, missing_keys: &mut Vec<(String, String)>) -> String { let pattern = format!(r"(?m)^(\s*{}\s*:\s*).*$", regex::escape(key)); match regex::Regex::new(&pattern) { Ok(re) => { if re.is_match(yaml) { // Key exists, replace it let replacement = format!("${{1}}{}", new_value); re.replace_all(yaml, replacement.as_str()).to_string() } else { // Key doesn't exist, track it missing_keys.push((key.to_string(), new_value.to_string())); yaml.to_string() } } Err(e) => { warn!("Failed to create regex for key '{}': {}", key, e); yaml.to_string() } } } /// Generate YAML content with comments for new files fn to_yaml_with_comments(&self) -> String { format!( r#"# ViewSkater User Settings # This file is loaded automatically when the application starts. # Settings specified here will override the default values. # Display FPS counter (useful for development/debugging) show_fps: {} # Show footer with file information show_footer: {} # Use horizontal split for dual-pane mode (false = vertical split) is_horizontal_split: {} # Synchronize zoom and pan between panes in dual-pane mode synced_zoom: {} # Enable mouse wheel zoom (false = mouse wheel navigates images) mouse_wheel_zoom: {} # Cache strategy: "cpu" or "gpu" # - "gpu": Stores decoded images in GPU memory (faster but uses more VRAM) # - "cpu": Stores decoded images in system RAM (slower but uses less VRAM) cache_strategy: "{}" # Compression strategy: "none" or "bc1" # - "none": No texture compression (higher quality, more VRAM usage) # - "bc1": BC1/DXT1 compression (lower quality, less VRAM usage, faster for large images) compression_strategy: "{}" # Slider type for navigation # - true: Dual slider (independent sliders for each pane) # - false: Single slider (shared across panes) is_slider_dual: {} # Show copy filename/filepath buttons in footer show_copy_buttons: {} # Show image metadata (resolution, file size) in footer show_metadata: {} # Use nearest-neighbor filtering for pixel-perfect scaling (good for pixel art) # - true: Sharp, blocky pixels when zoomed (nearest-neighbor) # - false: Smooth, interpolated pixels when zoomed (linear) nearest_neighbor_filter: {} # --- Advanced Settings --- # Cache window size (number of images to keep in cache) cache_size: {} # Max size for loading queue max_loading_queue_size: {} # Max size for being loaded queue max_being_loaded_queue_size: {} # Default window width (pixels) window_width: {} # Default window height (pixels) window_height: {} # Texture atlas size (affects slider performance, power of 2) atlas_size: {} # Double-click detection threshold (milliseconds) double_click_threshold_ms: {} # Max size for compressed file cache (bytes) archive_cache_size: {} # Warning threshold for solid archives (megabytes) archive_warning_threshold_mb: {} # --- COCO Settings --- # Disable polygon simplification for segmentation masks (more accurate but slower) coco_disable_simplification: {} # Mask rendering mode: "Polygon" (vector, scalable) or "Pixel" (raster, exact) coco_mask_render_mode: "{}" # --- Display Settings --- # Use binary file size units (KiB/MiB with 1024 divisor) instead of decimal (KB/MB with 1000) # - true: Binary units like `ls -lh` (1 KiB = 1024 bytes) # - false: Decimal units like GNOME/macOS/Windows (1 KB = 1000 bytes) use_binary_size: {} # Loading spinner location # - "Footer": Show spinner in the footer bar (default) # - "MenuBar": Show spinner in the menu bar (overlays in fullscreen mode) # - "None": Don't show loading spinner spinner_location: "{}" "#, self.show_fps, self.show_footer, self.is_horizontal_split, self.synced_zoom, self.mouse_wheel_zoom, self.cache_strategy, self.compression_strategy, self.is_slider_dual, self.show_copy_buttons, self.show_metadata, self.nearest_neighbor_filter, self.cache_size, self.max_loading_queue_size, self.max_being_loaded_queue_size, self.window_width, self.window_height, self.atlas_size, self.double_click_threshold_ms, self.archive_cache_size, self.archive_warning_threshold_mb, self.coco_disable_simplification, match self.coco_mask_render_mode { CocoMaskRenderMode::Polygon => "Polygon", CocoMaskRenderMode::Pixel => "Pixel", }, self.use_binary_size, match self.spinner_location { SpinnerLocation::Footer => "Footer", SpinnerLocation::MenuBar => "MenuBar", SpinnerLocation::None => "None", } ) } /// Convert cache_strategy string to CacheStrategy enum pub fn get_cache_strategy(&self) -> CacheStrategy { match self.cache_strategy.to_lowercase().as_str() { "cpu" => CacheStrategy::Cpu, "gpu" => CacheStrategy::Gpu, _ => { warn!("Unknown cache strategy '{}', defaulting to GPU", self.cache_strategy); CacheStrategy::Gpu } } } /// Convert compression_strategy string to CompressionStrategy enum pub fn get_compression_strategy(&self) -> CompressionStrategy { match self.compression_strategy.to_lowercase().as_str() { "none" => CompressionStrategy::None, "bc1" => CompressionStrategy::Bc1, _ => { warn!("Unknown compression strategy '{}', defaulting to None", self.compression_strategy); CompressionStrategy::None } } } } ================================================ FILE: src/settings_modal.rs ================================================ use iced_winit::core::{Element, Length, Alignment, Color}; use iced_winit::core::font::Font; use iced_widget::{row, column, container, text, button, Space, scrollable, text_input}; use iced_winit::core::Theme as WinitTheme; use iced_wgpu::Renderer; use iced_wgpu::engine::CompressionStrategy; use iced_aw::widget::tab_bar::tab_label::TabLabel; use iced_aw::tabs::Tabs; use crate::app::{Message, DataViewer}; use crate::cache::img_cache::CacheStrategy; use crate::widgets; use crate::settings::SpinnerLocation; /// Builds the settings modal dialog with tabs pub fn view_settings_modal<'a>(viewer: &'a DataViewer) -> Element<'a, Message, WinitTheme, Renderer> { // Create the tabs with compact styling #[cfg_attr(not(feature = "coco"), allow(unused_mut))] let mut tabs = Tabs::new(Message::SettingsTabSelected) .push( 0, // Tab ID TabLabel::Text("General".to_string()), // Label view_general_tab(viewer) // Content ) .push( 1, // Tab ID TabLabel::Text("Advanced".to_string()), // Label view_advanced_tab(viewer) // Content ); // Add COCO tab if feature is enabled #[cfg(feature = "coco")] { tabs = tabs.push( 2, // Tab ID TabLabel::Text("COCO".to_string()), // Label view_coco_tab(viewer) // Content ); } let tabs = tabs.set_active_tab(&viewer.settings.active_tab) .tab_bar_style(|theme: &WinitTheme, status| { use iced_aw::style::status::Status; // Highlight active tab with a tinted background, show hover feedback let tab_bg = match status { Status::Active => iced_winit::core::Background::Color( theme.extended_palette().primary.weak.color ), Status::Hovered => iced_winit::core::Background::Color( theme.extended_palette().background.strong.color ), _ => iced_winit::core::Background::Color(Color::TRANSPARENT), }; iced_aw::style::tab_bar::Style { background: Some(theme.extended_palette().background.weak.color.into()), border_color: Some(theme.extended_palette().background.strong.color), border_width: 0.0, tab_label_background: tab_bg, tab_label_border_color: theme.extended_palette().background.strong.color, tab_label_border_width: 1.0, icon_background: Some(iced_winit::core::Background::Color(Color::TRANSPARENT)), icon_border_radius: 0.0.into(), icon_color: theme.palette().text, text_color: theme.palette().text, } }) .tab_label_spacing(0) //.tab_label_padding(5.0) .tab_label_padding(2.0) .text_size(13.0) .width(Length::Fill) .height(Length::Fill); let content = column![ // Title row row![ text("Settings").size(18) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Bold, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), ] .align_y(Alignment::Center), // Tabs container(tabs) .height(Length::Fixed(270.0)) .padding(0), // Status message (always reserve space to prevent layout jump) // Use red for errors, green for success { let status_text = viewer.settings.save_status.as_deref().unwrap_or(" "); // Catch both "Error:" and "Error parsing" messages let is_error = status_text.contains("Error"); container( text(status_text).size(14) ) .style(move |theme: &WinitTheme| container::Style { text_color: Some(if is_error { theme.extended_palette().danger.strong.color } else { theme.extended_palette().success.strong.color }), ..container::Style::default() }) .height(Length::Fixed(18.0)) }, // Buttons row row![ button(text("Reset to Defaults")) .padding([3, 10]) .on_press(Message::ResetAdvancedSettings), button(text("Save")) .padding([3, 10]) .on_press(Message::SaveSettings), button(text("Close")) .padding([3, 10]) .on_press(Message::HideOptions), Space::with_width(Length::Fill), button(text("Open Settings Dir")) .padding([3, 10]) .on_press(Message::OpenSettingsDir), ] .spacing(10) .align_y(Alignment::Center) ] .spacing(5) .align_x(iced_winit::core::alignment::Horizontal::Left) .width(Length::Fixed(700.0)) .height(Length::Fixed(360.0)); container(content) .padding(15) .style(|theme: &WinitTheme| { iced_widget::container::Style { background: Some(theme.extended_palette().background.base.color.into()), text_color: Some(theme.extended_palette().primary.weak.text), border: iced_winit::core::Border { color: theme.extended_palette().background.strong.color, width: 1.0, radius: iced_winit::core::border::Radius::from(8.0), }, ..Default::default() } }) .into() } /// General tab content: Display, Performance, and Controls fn view_general_tab<'a>(viewer: &'a DataViewer) -> Element<'a, Message, WinitTheme, Renderer> { // Left column - Display & Performance let left_column = column![ text("Display").size(16) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), container( widgets::toggler::Toggler::new( Some("Show FPS Display".into()), viewer.show_fps, Message::ToggleFpsDisplay, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( widgets::toggler::Toggler::new( Some("Show Footer".into()), viewer.show_footer, Message::ToggleFooter, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( widgets::toggler::Toggler::new( Some("Show Copy Buttons".into()), viewer.show_copy_buttons, Message::ToggleCopyButtons, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( widgets::toggler::Toggler::new( Some("Show Image Metadata".into()), viewer.show_metadata, Message::ToggleMetadataDisplay, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( widgets::toggler::Toggler::new( Some("Nearest-Neighbor Filter (for pixel art)".into()), viewer.nearest_neighbor_filter, Message::ToggleNearestNeighborFilter, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), Space::with_height(5), container( text("Loading Spinner").size(13) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( row![ iced_widget::Radio::new( "Footer", SpinnerLocation::Footer, Some(viewer.spinner_location), Message::SetSpinnerLocation, ), iced_widget::Radio::new( "Menu Bar", SpinnerLocation::MenuBar, Some(viewer.spinner_location), Message::SetSpinnerLocation, ), iced_widget::Radio::new( "None", SpinnerLocation::None, Some(viewer.spinner_location), Message::SetSpinnerLocation, ), ] .spacing(15) ).padding([0, 10]), container( widgets::toggler::Toggler::new( Some("Horizontal Split".into()), viewer.is_horizontal_split, Message::ToggleSplitOrientation, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), Space::with_height(10), text("Performance").size(16) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), container( widgets::toggler::Toggler::new( Some("BC1 Texture Compression".into()), viewer.compression_strategy == CompressionStrategy::Bc1, |enabled| { if enabled { Message::SetCompressionStrategy(CompressionStrategy::Bc1) } else { Message::SetCompressionStrategy(CompressionStrategy::None) } }, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( widgets::toggler::Toggler::new( Some("GPU Cache (vs CPU)".into()), viewer.cache_strategy == CacheStrategy::Gpu, |enabled| { if enabled { Message::SetCacheStrategy(CacheStrategy::Gpu) } else { Message::SetCacheStrategy(CacheStrategy::Cpu) } }, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), ] .spacing(3) .width(Length::FillPortion(1)); // Right column - Controls and Features let right_column = column![ text("Controls").size(16) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), container( widgets::toggler::Toggler::new( Some("Sync Zoom/Pan".into()), viewer.synced_zoom, Message::ToggleSyncedZoom, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( widgets::toggler::Toggler::new( Some("Mouse Wheel Zoom".into()), viewer.mouse_wheel_zoom, Message::ToggleMouseWheelZoom, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( widgets::toggler::Toggler::new( Some("Dual Slider".into()), viewer.is_slider_dual, Message::ToggleSliderType, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), ] .spacing(3) .width(Length::FillPortion(1)); scrollable( container( row![left_column, right_column] .spacing(30) ) .padding([5, 10]) ) .height(Length::Fill) .into() } /// Helper function to create a labeled text input row (editable) fn labeled_text_input_row<'a>( label: &'a str, field_name: &'a str, value: String, ) -> iced_widget::Row<'a, Message, WinitTheme, Renderer> { let field_name_owned = field_name.to_string(); row![ text(label).size(14).width(Length::Fixed(250.0)), text_input("", &value) .size(14) .width(Length::Fixed(150.0)) .on_input(move |new_value| { Message::AdvancedSettingChanged(field_name_owned.clone(), new_value) }), ] .spacing(10) .align_y(Alignment::Center) } /// Advanced tab content: Editable config constants fn view_advanced_tab<'a>(viewer: &'a DataViewer) -> Element<'a, Message, WinitTheme, Renderer> { // Helper to get value from HashMap with fallback let get_value = |key: &str| -> String { viewer.settings.advanced_input .get(key) .cloned() .unwrap_or_default() }; let content = column![ text("Advanced Settings").size(16) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), Space::with_height(5), text("Note: Changes take effect after saving and restarting the application.").size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color) } }), Space::with_height(10), // Cache settings text("Cache Settings").size(14) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), labeled_text_input_row("Cache Size:", "cache_size", get_value("cache_size")), labeled_text_input_row("Max Loading Queue Size:", "max_loading_queue_size", get_value("max_loading_queue_size")), labeled_text_input_row("Max Being Loaded Queue Size:", "max_being_loaded_queue_size", get_value("max_being_loaded_queue_size")), Space::with_height(10), // Window settings text("Window Settings").size(14) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), labeled_text_input_row("Texture Atlas Size:", "atlas_size", get_value("atlas_size")), Space::with_height(10), // Other settings text("Other Settings").size(14) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), labeled_text_input_row("Double-Click Threshold (ms):", "double_click_threshold_ms", get_value("double_click_threshold_ms")), labeled_text_input_row("Archive Cache Size (MB):", "archive_cache_size", get_value("archive_cache_size")), labeled_text_input_row("Archive Warning Threshold (MB):", "archive_warning_threshold_mb", get_value("archive_warning_threshold_mb")), ] .spacing(3); // Center the content with fixed width, scrollbar on right edge let centered_content = container( container(content) .width(Length::Fixed(480.0)) // Fixed width for content .padding([5, 10]) ) .width(Length::Fill) .center_x(Length::Fill); scrollable(centered_content) .height(Length::Fill) .into() } /// COCO tab content: COCO-specific settings #[cfg(feature = "coco")] fn view_coco_tab<'a>(viewer: &'a DataViewer) -> Element<'a, Message, WinitTheme, Renderer> { use crate::settings::CocoMaskRenderMode; let mut content = column![ text("COCO Dataset Settings").size(16) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), Space::with_height(10), text("Segmentation Masks").size(14) .font(Font { family: iced_winit::core::font::Family::Name("Roboto"), weight: iced_winit::core::font::Weight::Medium, stretch: iced_winit::core::font::Stretch::Normal, style: iced_winit::core::font::Style::Normal, }), Space::with_height(5), container( text("Rendering Mode").size(13) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }), container( row![ iced_widget::Radio::new( "Polygon (Vector)", CocoMaskRenderMode::Polygon, Some(viewer.coco_mask_render_mode), Message::SetCocoMaskRenderMode, ), iced_widget::horizontal_space(), iced_widget::Radio::new( "Pixel (Raster)", CocoMaskRenderMode::Pixel, Some(viewer.coco_mask_render_mode), Message::SetCocoMaskRenderMode, ), ] .spacing(20) ).padding([0, 20]), Space::with_height(3), container( text("Polygon: Smooth scaling, slight approximation\nPixel: Exact RLE representation, better performance") .size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color) } }) ).padding([0, 20]), Space::with_height(10), ] .spacing(3); // Only show polygon simplification toggle when polygon mode is selected if viewer.coco_mask_render_mode == CocoMaskRenderMode::Polygon { content = content.push( container( widgets::toggler::Toggler::new( Some("Disable Polygon Simplification".into()), viewer.coco_disable_simplification, Message::ToggleCocoSimplification, ).width(Length::Fill) ).style(|_theme: &WinitTheme| container::Style { text_color: Some(Color::from_rgb(0.878, 0.878, 0.878)), ..container::Style::default() }) ); content = content.push(Space::with_height(5)); content = content.push( container( text("When enabled, RLE masks are converted to polygons without simplification,\npreserving maximum accuracy at the cost of slightly slower rendering.") .size(12) .style(|theme: &WinitTheme| { iced_widget::text::Style { color: Some(theme.extended_palette().background.weak.color) } }) ).padding([0, 20]) ); } let content = content; scrollable( container(content) .padding([5, 10]) ) .height(Length::Fill) .into() } ================================================ FILE: src/ui.rs ================================================ #[cfg(target_os = "linux")] mod other_os { //pub use iced; pub use iced_custom as iced; } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; } #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; use iced_widget::{container, Container, row, column, horizontal_space, text, button, center}; #[cfg(feature = "coco")] use iced_widget::Stack; use iced_winit::core::{Color, Element, Length, Alignment}; use iced_winit::core::alignment; use iced_winit::core::alignment::Horizontal; use iced_winit::core::font::Font; use iced_winit::core::Theme as WinitTheme; use iced_wgpu::Renderer; use iced_wgpu::graphics::text::{font_system, cosmic_text, measure as measure_buffer, to_attributes, to_shaping}; use iced_winit::core::text::Shaping; use crate::pane::Pane; use crate::{menu as app_menu}; use app_menu::button_style; use crate::menu::PaneLayout; use crate::{app::Message, DataViewer}; use crate::widgets::shader::image_shader::ImageShader; use crate::widgets::{split::Axis, viewer, dualslider::DualSlider}; use crate::{CURRENT_FPS, CURRENT_MEMORY_USAGE, pane::IMAGE_RENDER_FPS}; use crate::menu::MENU_BAR_HEIGHT; use iced_widget::tooltip; use crate::widgets::synced_image_split::SyncedImageSplit; use crate::widgets::circular::mini_circular; use crate::settings::{SpinnerLocation, WindowState}; #[cfg(feature = "selection")] use crate::selection_manager::ImageMark; fn icon<'a, Message>(codepoint: char) -> Element<'a, Message, WinitTheme, Renderer> { const ICON_FONT: Font = Font::with_name("viewskater-fonts"); text(codepoint) .font(ICON_FONT) .size(18) .into() } fn file_copy_icon<'a, Message>() -> Element<'a, Message, WinitTheme, Renderer> { icon('\u{E804}') } fn folder_copy_icon<'a, Message>() -> Element<'a, Message, WinitTheme, Renderer> { icon('\u{E805}') } fn image_copy_icon<'a, Message>() -> Element<'a, Message, WinitTheme, Renderer> { icon('\u{E806}') } /// Helper struct to pass ML mark badge and COCO badge into footer function pub struct FooterOptions { pub mark_badge: Option>, pub coco_badge: Option>, } impl FooterOptions { pub fn new() -> Self { Self { mark_badge: None, coco_badge: None, } } #[cfg(feature = "selection")] pub fn with_mark(mut self, mark: crate::selection_manager::ImageMark) -> Self { self.mark_badge = Some(crate::widgets::selection_widget::mark_badge(mark)); self } #[cfg(feature = "coco")] #[allow(dead_code)] pub fn with_coco(mut self, has_annotations: bool, num_annotations: usize) -> Self { self.coco_badge = Some(crate::coco::widget::coco_badge(has_annotations, num_annotations)); self } #[allow(dead_code)] pub fn get_mark_badge(self) -> Element<'static, Message, WinitTheme, Renderer> { self.mark_badge.unwrap_or_else(|| { #[cfg(feature = "selection")] { crate::widgets::selection_widget::empty_badge() } #[cfg(not(feature = "selection"))] { container(text("")).width(0).height(0).into() } }) } #[allow(dead_code)] pub fn get_coco_badge(self) -> Element<'static, Message, WinitTheme, Renderer> { self.coco_badge.unwrap_or_else(|| { #[cfg(feature = "coco")] { crate::coco::widget::empty_badge() } #[cfg(not(feature = "coco"))] { container(text("")).width(0).height(0).into() } }) } } /// Responsive footer layout state struct ResponsiveFooterState { metadata: Option, show_spinner: bool, show_copy_buttons: bool, footer_text: String, } /// Measures the width of text using the actual font system /// Uses Font::MONOSPACE at size 14 to match footer text rendering fn measure_text_width(text: &str) -> f32 { const FONT_SIZE: f32 = 14.0; const LINE_HEIGHT: f32 = 1.3; // Default line height multiplier let mut font_system_guard = font_system() .write() .expect("Failed to acquire font system lock"); let mut buffer = cosmic_text::Buffer::new( font_system_guard.raw(), cosmic_text::Metrics::new(FONT_SIZE, FONT_SIZE * LINE_HEIGHT), ); // Set a large width so text doesn't wrap buffer.set_size(font_system_guard.raw(), Some(10000.0), Some(100.0)); buffer.set_text( font_system_guard.raw(), text, to_attributes(Font::MONOSPACE), to_shaping(Shaping::Basic), ); measure_buffer(&buffer).width } /// Determines responsive footer layout based on available width /// Phases: /// 1. Full metadata (resolution + file size) + spinner + buttons + index/total /// 2. Resolution with "pixels" + spinner + buttons + index/total /// 3. Dimensions only + spinner + buttons + index/total /// 4. No metadata + spinner + buttons + index/total /// 5. No metadata + no spinner + buttons + index/total /// 6. No metadata + no spinner + no buttons + index/total /// 7. No metadata + no spinner + no buttons + index only /// 8. Nothing (empty footer) fn get_responsive_footer_state( available_width: f32, metadata_text: &Option, footer_text: &str, show_spinner: bool, show_copy_buttons: bool, ) -> ResponsiveFooterState { // Fixed widths for non-text elements const BUTTON_WIDTH: f32 = 26.0; // Each copy button: 18px icon + padding const BUTTON_SPACING: f32 = 3.0; // Spacing between buttons const SPINNER_WIDTH: f32 = 18.0; // Mini spinner size const FOOTER_PADDING: f32 = 6.0; // Footer container padding (3px each side) const ELEMENT_SPACING: f32 = 3.0; // Spacing between row elements const MIN_MARGIN: f32 = 5.0; // Minimum margin before hiding // Parse footer_text to get index and total (format: "index/total") let (index_str, _total_str) = footer_text.split_once('/').unwrap_or((footer_text, "")); let index_only = index_str.to_string(); // Measure actual text widths dynamically let full_footer_width = measure_text_width(footer_text); let index_only_width = measure_text_width(&index_only); let buttons_width = if show_copy_buttons { BUTTON_WIDTH * 2.0 + BUTTON_SPACING } else { 0.0 }; let spinner_width = if show_spinner { SPINNER_WIDTH + ELEMENT_SPACING } else { 0.0 }; // Phase 8: Nothing fits - hide everything if available_width < index_only_width + FOOTER_PADDING + MIN_MARGIN { return ResponsiveFooterState { metadata: None, show_spinner: false, show_copy_buttons: false, footer_text: String::new(), }; } // Phase 7: Only index (no total) if available_width < full_footer_width + FOOTER_PADDING + MIN_MARGIN { return ResponsiveFooterState { metadata: None, show_spinner: false, show_copy_buttons: false, footer_text: index_only, }; } // Phase 6: Index/total but no buttons or spinner if available_width < full_footer_width + buttons_width + FOOTER_PADDING + ELEMENT_SPACING + MIN_MARGIN { return ResponsiveFooterState { metadata: None, show_spinner: false, show_copy_buttons: false, footer_text: footer_text.to_string(), }; } // Phase 5: Buttons + index/total but no spinner or metadata if available_width < full_footer_width + buttons_width + spinner_width + FOOTER_PADDING + ELEMENT_SPACING + MIN_MARGIN { return ResponsiveFooterState { metadata: None, show_spinner: false, show_copy_buttons, footer_text: footer_text.to_string(), }; } // Phase 4: Spinner + buttons + index/total but no metadata let right_side_width = spinner_width + buttons_width + full_footer_width + ELEMENT_SPACING; let Some(meta) = metadata_text else { return ResponsiveFooterState { metadata: None, show_spinner, show_copy_buttons, footer_text: footer_text.to_string(), }; }; // We need space for: left_content + horizontal_space + right_content // horizontal_space is flexible, but we need at least some gap let available_for_meta = available_width - right_side_width - FOOTER_PADDING - ELEMENT_SPACING; // Extract resolution parts for progressive display if let Some(pixels_pos) = meta.find(" pixels") { let resolution_with_pixels = &meta[..pixels_pos + 7]; // "1920 x 1080 pixels" let resolution_only = &meta[..pixels_pos]; // "1920 x 1080" let resolution_only_width = measure_text_width(resolution_only); let resolution_with_pixels_width = measure_text_width(resolution_with_pixels); let full_meta_width = measure_text_width(meta); // Not enough for even dimensions - no metadata if available_for_meta < resolution_only_width + MIN_MARGIN { return ResponsiveFooterState { metadata: None, show_spinner, show_copy_buttons, footer_text: footer_text.to_string(), }; } // Phase 3: Dimensions only (e.g., "1920 x 1080") if available_for_meta < resolution_with_pixels_width + MIN_MARGIN { return ResponsiveFooterState { metadata: Some(resolution_only.to_string()), show_spinner, show_copy_buttons, footer_text: footer_text.to_string(), }; } // Phase 2: Resolution with "pixels" (e.g., "1920 x 1080 pixels") if available_for_meta < full_meta_width + MIN_MARGIN { return ResponsiveFooterState { metadata: Some(resolution_with_pixels.to_string()), show_spinner, show_copy_buttons, footer_text: footer_text.to_string(), }; } } // Phase 1: Full metadata fits ResponsiveFooterState { metadata: Some(meta.clone()), show_spinner, show_copy_buttons, footer_text: footer_text.to_string(), } } pub fn get_footer( footer_text: String, metadata_text: Option, pane_index: usize, show_copy_buttons: bool, show_spinner: bool, spinner_location: SpinnerLocation, options: FooterOptions, available_width: f32, ) -> Container<'static, Message, WinitTheme, Renderer> { // Only show spinner in footer if spinner_location is Footer let show_spinner = show_spinner && spinner_location == SpinnerLocation::Footer; // Get responsive footer state based on available width let state = get_responsive_footer_state( available_width, &metadata_text, &footer_text, show_spinner, show_copy_buttons, ); // Phase 6: Empty footer if state.footer_text.is_empty() { return container::(text("")) .width(Length::Fill) .height(32) .padding(3); } // Extract badges from options let mark_badge = options.mark_badge.unwrap_or_else(|| { #[cfg(feature = "selection")] { crate::widgets::selection_widget::empty_badge() } #[cfg(not(feature = "selection"))] { container(text("")).width(0).height(0).into() } }); let coco_badge = options.coco_badge.unwrap_or_else(|| { #[cfg(feature = "coco")] { crate::coco::widget::empty_badge() } #[cfg(not(feature = "coco"))] { container(text("")).width(0).height(0).into() } }); // Left side: metadata (resolution and file size) - EoG style let left_content: Element<'_, Message, WinitTheme, Renderer> = if let Some(meta) = state.metadata { text(meta) .font(Font::MONOSPACE) .style(|_theme| iced::widget::text::Style { color: Some(Color::from([0.8, 0.8, 0.8])) }) .size(14) .into() } else { text("") .size(14) .into() }; // Optional loading spinner (shown during background loading, hidden when footer is narrow) let spinner_element: Element<'_, Message, WinitTheme, Renderer> = if state.show_spinner { mini_circular() } else { // Empty placeholder to maintain spacing container(text("")).width(0).height(0).into() }; // Right side: spinner, copy buttons, badges, and index let right_content: Element<'_, Message, WinitTheme, Renderer> = if state.show_copy_buttons { let copy_filename_button = tooltip( button(file_copy_icon()) .padding(iced::padding::all(2)) .style(|_theme: &WinitTheme, _status: button::Status| button_style(_theme, _status, "labeled")) .on_press(Message::CopyFilename(pane_index)), container(text("Copy filename").size(14)) .padding(5) .style(|theme: &WinitTheme| container::Style { text_color: Some(Color::from([1.0, 1.0, 1.0])), background: Some(theme.extended_palette().background.strong.color.into()), border: iced::Border { radius: 4.0.into(), width: 0.0, color: Color::TRANSPARENT, }, ..container::Style::default() }), tooltip::Position::Top, ); let copy_filepath_button = tooltip( button(folder_copy_icon()) .padding(iced::padding::all(2)) .style(|_theme: &WinitTheme, _status: button::Status| button_style(_theme, _status, "labeled")) .on_press(Message::CopyFilePath(pane_index)), container(text("Copy file path").size(14)) .padding(5) .style(|theme: &WinitTheme| container::Style { text_color: Some(Color::from([1.0, 1.0, 1.0])), background: Some(theme.extended_palette().background.strong.color.into()), border: iced::Border { radius: 4.0.into(), width: 0.0, color: Color::TRANSPARENT, }, ..container::Style::default() }), tooltip::Position::Top, ); let copy_image_button = tooltip( button(image_copy_icon()) .padding(iced::padding::all(2)) .style(|_theme: &WinitTheme, _status: button::Status| button_style(_theme, _status, "labeled")) .on_press(Message::CopyImage(pane_index)), container(text("Copy image").size(14)) .padding(5) .style(|theme: &WinitTheme| container::Style { text_color: Some(Color::from([1.0, 1.0, 1.0])), background: Some(theme.extended_palette().background.strong.color.into()), border: iced::Border { radius: 4.0.into(), width: 0.0, color: Color::TRANSPARENT, }, ..container::Style::default() }), tooltip::Position::Top, ); row![ spinner_element, copy_image_button, copy_filepath_button, copy_filename_button, mark_badge, coco_badge, text(state.footer_text) .font(Font::MONOSPACE) .style(|_theme| iced::widget::text::Style { color: Some(Color::from([0.8, 0.8, 0.8])) }) .size(14) ] .align_y(Alignment::Center) .spacing(3) .into() } else { row![ spinner_element, mark_badge, coco_badge, text(state.footer_text) .font(Font::MONOSPACE) .style(|_theme| iced::widget::text::Style { color: Some(Color::from([0.8, 0.8, 0.8])) }) .size(14) ] .align_y(Alignment::Center) .spacing(3) .into() }; // Combine left (metadata) and right (index + buttons) with space between container::( row![ left_content, horizontal_space(), right_content ] .align_y(Alignment::Center) ) .width(Length::Fill) .height(32) .padding(3) } pub fn build_ui(app: &DataViewer) -> Container<'_, Message, WinitTheme, Renderer> { // Helper to get the current image mark for a pane (ML tools only) #[cfg(feature = "selection")] let get_mark_for_pane = |pane_index: usize| -> ImageMark { if let Some(pane) = app.panes.get(pane_index) { if pane.dir_loaded && !pane.img_cache.image_paths.is_empty() { let path = &pane.img_cache.image_paths[pane.img_cache.current_index]; let filename = path.file_name().to_string(); return app.selection_manager.get_mark(&filename); } } ImageMark::Unmarked }; let mb = app_menu::build_menu(app); let is_fullscreen = app.window_state == WindowState::FullScreen; let cursor_on_top = app.cursor_on_top; let cursor_on_menu = app.cursor_on_menu; let cursor_on_footer = app.cursor_on_footer; let show_option = app.settings.is_visible(); // Check if spinner should be shown in menu bar let show_menu_bar_spinner = app.spinner_location == SpinnerLocation::MenuBar && app.panes.iter().any(|p| p.loading_started_at .is_some_and(|start| start.elapsed() > std::time::Duration::from_secs(1))); // Reserve fixed width for spinner only when MenuBar location is selected let menu_bar_spinner: Element<'_, Message, WinitTheme, Renderer> = if show_menu_bar_spinner { container(mini_circular()).padding([0, 5]).width(28).into() } else if app.spinner_location == SpinnerLocation::MenuBar { container(text("")).width(28).height(0).into() } else { container(text("")).width(0).height(0).into() }; let top_bar = container( row![ mb, horizontal_space(), if !is_fullscreen { get_fps_container(app) } else { container(text("")).width(0).height(0) }, menu_bar_spinner ] .align_y(alignment::Vertical::Center) ) .align_y(alignment::Vertical::Center) .width(Length::Fill); // Menu bar spinner for fullscreen mode (same logic) let fullscreen_menu_bar_spinner: Element<'_, Message, WinitTheme, Renderer> = if show_menu_bar_spinner { container(mini_circular()).padding([0, 5]).width(28).into() } else if app.spinner_location == SpinnerLocation::MenuBar { container(text("")).width(28).height(0).into() } else { container(text("")).width(0).height(0).into() }; let fps_bar = if is_fullscreen { container ( row![get_fps_container(app), fullscreen_menu_bar_spinner] .align_y(alignment::Vertical::Center) ).align_x(alignment::Horizontal::Right) .width(Length::Fill) } else { container(text("")).width(0).height(0) }; match app.pane_layout { PaneLayout::SinglePane => { // Choose the appropriate widget based on slider movement state let first_img = if app.panes[0].dir_loaded { // First, create the base image widget (either slider or shader) let base_image_widget = if app.use_slider_image_for_render && app.panes[0].slider_image.is_some() { // Use regular Image widget during slider movement (much faster) let image_handle = app.panes[0].slider_image.clone().unwrap(); center({ #[cfg(feature = "coco")] let mut viewer = viewer::Viewer::new(image_handle) .width(Length::Fill) .height(Length::Fill) .content_fit(iced_winit::core::ContentFit::Contain); #[cfg(not(feature = "coco"))] let viewer = viewer::Viewer::new(image_handle) .width(Length::Fill) .height(Length::Fill) .content_fit(iced_winit::core::ContentFit::Contain); #[cfg(feature = "coco")] { viewer = viewer .with_zoom_state(app.panes[0].zoom_scale, app.panes[0].zoom_offset) .pane_index(0) .on_zoom_change(|pane_idx, scale, offset| { Message::CocoAction(crate::coco::widget::CocoMessage::ZoomChanged( pane_idx, scale, offset )) }); } viewer }) } else if let Some(scene) = app.panes[0].scene.as_ref() { // Fixed: Pass Arc reference correctly #[cfg(feature = "coco")] let mut shader = ImageShader::new(Some(scene)) .width(Length::Fill) .height(Length::Fill) .content_fit(iced_winit::core::ContentFit::Contain) .horizontal_split(false) .with_interaction_state(app.panes[0].mouse_wheel_zoom, app.panes[0].ctrl_pressed) .double_click_threshold_ms(app.double_click_threshold_ms) .use_nearest_filter(app.nearest_neighbor_filter); #[cfg(not(feature = "coco"))] let shader = ImageShader::new(Some(scene)) .width(Length::Fill) .height(Length::Fill) .content_fit(iced_winit::core::ContentFit::Contain) .horizontal_split(false) .with_interaction_state(app.panes[0].mouse_wheel_zoom, app.panes[0].ctrl_pressed) .double_click_threshold_ms(app.double_click_threshold_ms) .use_nearest_filter(app.nearest_neighbor_filter); #[cfg(feature = "coco")] { shader = shader.with_zoom_state(app.panes[0].zoom_scale, app.panes[0].zoom_offset); } // Set up zoom change callback for COCO bbox rendering #[cfg(feature = "coco")] { shader = shader .pane_index(0) .image_index(app.panes[0].img_cache.current_index) .on_zoom_change(|pane_idx, scale, offset| { Message::CocoAction(crate::coco::widget::CocoMessage::ZoomChanged( pane_idx, scale, offset )) }); } center(shader) } else { return container(text("No image loaded")); }; // Then, optionally add annotations overlay on top #[cfg(feature = "coco")] let with_annotations = { if (app.panes[0].show_bboxes || app.panes[0].show_masks) && app.annotation_manager.has_annotations() { // Determine which index to use for annotation lookup based on rendering mode let annotation_index = if app.use_slider_image_for_render && app.panes[0].slider_image.is_some() { // Slider mode: use slider_image_position app.panes[0].slider_image_position .or(app.panes[0].current_image_index) .unwrap_or(app.panes[0].img_cache.current_index) } else { // Normal mode: use current_image_index app.panes[0].current_image_index .unwrap_or(app.panes[0].img_cache.current_index) }; if let Some(path_source) = app.panes[0].img_cache.image_paths.get(annotation_index) { let filename = path_source.file_name(); // Look up annotations for this image if let Some(annotations) = app.annotation_manager.get_annotations(&filename) { // Get image dimensions based on rendering mode let image_size = if app.use_slider_image_for_render && app.panes[0].slider_image.is_some() { // Slider mode: use slider_image_dimensions app.panes[0].slider_image_dimensions .unwrap_or((app.panes[0].current_image.width(), app.panes[0].current_image.height())) } else { // Normal mode: use current_image dimensions (app.panes[0].current_image.width(), app.panes[0].current_image.height()) }; // log::debug!("UI: Using dimensions for annotation_index={}: {:?} (slider_mode={})", // annotation_index, image_size, app.use_slider_image_for_render); // Check if this image has invalid annotations let has_invalid = app.annotation_manager.has_invalid_annotations(&filename); // Create bbox/mask overlay log::debug!("UI: Creating annotation overlay with zoom_scale={:.2}, zoom_offset=({:.1}, {:.1})", app.panes[0].zoom_scale, app.panes[0].zoom_offset.x, app.panes[0].zoom_offset.y); let bbox_overlay = crate::coco::overlay::render_bbox_overlay( annotations, image_size, app.panes[0].zoom_scale, app.panes[0].zoom_offset, app.panes[0].show_bboxes, app.panes[0].show_masks, has_invalid, app.coco_mask_render_mode, app.coco_disable_simplification, ); // Stack image and annotations container( Stack::new() .push(base_image_widget) .push(bbox_overlay) ) .width(Length::Fill) .height(Length::Fill) .padding(0) } else { // No annotations for this image container(base_image_widget) .width(Length::Fill) .height(Length::Fill) .padding(0) } } else { container(base_image_widget) .width(Length::Fill) .height(Length::Fill) .padding(0) } } else { // Annotations disabled or no annotations loaded container(base_image_widget) .width(Length::Fill) .height(Length::Fill) .padding(0) } }; #[cfg(not(feature = "coco"))] let with_annotations = container(base_image_widget) .width(Length::Fill) .height(Length::Fill) .padding(0); with_annotations.into() } else { // Use build_ui_container even when dir not loaded to show loading spinner app.panes[0].build_ui_container( app.use_slider_image_for_render, app.is_horizontal_split, app.double_click_threshold_ms, app.nearest_neighbor_filter ) }; let footer = if app.show_footer && app.panes[0].dir_loaded { // Use slider position during slider movement, otherwise use current_image_index let display_index = if app.use_slider_image_for_render && app.panes[0].slider_image_position.is_some() { app.panes[0].slider_image_position.unwrap() } else { app.panes[0].current_image_index.unwrap_or(app.panes[0].img_cache.current_index) }; let footer_text = format!("{}/{}", display_index + 1, app.panes[0].img_cache.num_files); // Generate metadata text for footer (EoG style: "1920x1080 pixels 2.5 MB") let metadata_text = if app.show_metadata { app.panes[0].current_image_metadata.as_ref().map(|m| format!("{} pixels {}", m.resolution_string(), m.file_size_string(app.use_binary_size)) ) } else { None }; // Show spinner after 1 second of loading let show_spinner = app.panes[0].loading_started_at .is_some_and(|start| start.elapsed() > std::time::Duration::from_secs(1)); let options = { #[cfg(feature = "selection")] { FooterOptions::new().with_mark(get_mark_for_pane(0)) } #[cfg(not(feature = "selection"))] { FooterOptions::new() } }; get_footer(footer_text, metadata_text, 0, app.show_copy_buttons, show_spinner, app.spinner_location, options, app.window_width) } else { container(text("")).height(0) }; let slider = if app.panes[0].dir_loaded && app.panes[0].img_cache.num_files > 1 { container(DualSlider::new( 0..=(app.panes[0].img_cache.num_files - 1) as u16, app.slider_value, -1, Message::SliderChanged, Message::SliderReleased, ) .width(Length::Fill)) } else { container(text("")).height(0) }; let slider_controls = slider .width(Length::Fill) .height(Length::Shrink) .align_x(Horizontal::Center); // Create the column WITHOUT converting to Element first center( container( if is_fullscreen && !show_option &&(cursor_on_top || cursor_on_menu) { column![top_bar, fps_bar, first_img] } else if is_fullscreen && cursor_on_footer { column![fps_bar, first_img, slider_controls, footer] } else if is_fullscreen { column![fps_bar, first_img] } else {column![ top_bar, first_img, slider_controls, footer ]} ) .style(|theme| container::Style { background: Some(theme.extended_palette().background.base.color.into()), ..container::Style::default() }) .width(Length::Fill) .height(Length::Fill) ).align_x(Horizontal::Center) }, PaneLayout::DualPane => { if app.is_slider_dual { // Prepare footer options for both panes let footer_options = [ { #[cfg(feature = "selection")] { FooterOptions::new().with_mark(get_mark_for_pane(0)) } #[cfg(not(feature = "selection"))] { FooterOptions::new() } }, { #[cfg(feature = "selection")] { FooterOptions::new().with_mark(get_mark_for_pane(1)) } #[cfg(not(feature = "selection"))] { FooterOptions::new() } }, ]; debug!("build_ui (dual_pane_slider2): app.nearest_neighbor_filter = {}", app.nearest_neighbor_filter); let panes = build_ui_dual_pane_slider2( &app.panes, app.divider_position, app.show_footer, app.use_slider_image_for_render, app.is_horizontal_split, app.synced_zoom, app.show_copy_buttons, app.show_metadata, app.double_click_threshold_ms, footer_options, app.nearest_neighbor_filter, app.use_binary_size, app.spinner_location, app.window_width, ); container( column![ top_bar, panes ] ) .style(|theme| container::Style { background: Some(theme.extended_palette().background.base.color.into()), ..container::Style::default() }) .width(Length::Fill) .height(Length::Fill) } else { // Pass synced_zoom parameter debug!("build_ui (dual_pane_slider1): app.nearest_neighbor_filter = {}", app.nearest_neighbor_filter); let panes = build_ui_dual_pane_slider1( &app.panes, app.divider_position, app.use_slider_image_for_render, app.is_horizontal_split, app.synced_zoom, app.double_click_threshold_ms, app.nearest_neighbor_filter, ); // Use slider position during slider movement, otherwise use current_image_index let display_index_0 = if app.use_slider_image_for_render && app.panes[0].slider_image_position.is_some() { app.panes[0].slider_image_position.unwrap() } else { app.panes[0].current_image_index.unwrap_or(app.panes[0].img_cache.current_index) }; let display_index_1 = if app.use_slider_image_for_render && app.panes[1].slider_image_position.is_some() { app.panes[1].slider_image_position.unwrap() } else { app.panes[1].current_image_index.unwrap_or(app.panes[1].img_cache.current_index) }; let footer_texts = [ format!("{}/{}", display_index_0 + 1, app.panes[0].img_cache.num_files), format!("{}/{}", display_index_1 + 1, app.panes[1].img_cache.num_files) ]; // Generate metadata text for each pane (EoG style) let metadata_texts = if app.show_metadata { [ app.panes[0].current_image_metadata.as_ref().map(|m| format!("{} pixels {}", m.resolution_string(), m.file_size_string(app.use_binary_size)) ), app.panes[1].current_image_metadata.as_ref().map(|m| format!("{} pixels {}", m.resolution_string(), m.file_size_string(app.use_binary_size)) ), ] } else { [None, None] }; let footer = if app.show_footer && (app.panes[0].dir_loaded || app.panes[1].dir_loaded) { // Show spinner after 1 second of loading let show_spinner_0 = app.panes[0].loading_started_at .is_some_and(|start| start.elapsed() > std::time::Duration::from_secs(1)); let show_spinner_1 = app.panes[1].loading_started_at .is_some_and(|start| start.elapsed() > std::time::Duration::from_secs(1)); let options0 = { #[cfg(feature = "selection")] { FooterOptions::new().with_mark(get_mark_for_pane(0)) } #[cfg(not(feature = "selection"))] { FooterOptions::new() } }; let options1 = { #[cfg(feature = "selection")] { FooterOptions::new().with_mark(get_mark_for_pane(1)) } #[cfg(not(feature = "selection"))] { FooterOptions::new() } }; // Each pane gets half the window width in dual mode let pane_width = app.window_width / 2.0; row![ get_footer(footer_texts[0].clone(), metadata_texts[0].clone(), 0, app.show_copy_buttons, show_spinner_0, app.spinner_location, options0, pane_width), get_footer(footer_texts[1].clone(), metadata_texts[1].clone(), 1, app.show_copy_buttons, show_spinner_1, app.spinner_location, options1, pane_width) ] } else { row![] }; let max_num_files = app.panes.iter().map(|p| p.img_cache.num_files).max().unwrap_or(0); let slider = if app.panes.iter().any(|p| p.dir_loaded) && max_num_files > 1 { container( DualSlider::new( 0..=(max_num_files - 1) as u16, app.slider_value, -1, Message::SliderChanged, Message::SliderReleased, ).width(Length::Fill) ) .width(Length::Fill) .height(Length::Shrink) } else { container(text("")).height(0) }; container( if is_fullscreen && !show_option &&(cursor_on_top || cursor_on_menu) { column![top_bar, fps_bar, panes] } else if is_fullscreen && cursor_on_footer { column![fps_bar, panes, slider, footer] } else if is_fullscreen { column![fps_bar, panes] } else { column![ top_bar, panes, slider, footer ] } ).style(|theme| container::Style { background: Some(theme.extended_palette().background.base.color.into()), ..container::Style::default() }) .width(Length::Fill) .height(Length::Fill) } } } } pub fn build_ui_dual_pane_slider1( panes: &[Pane], divider_position: Option, use_slider_image_for_render: bool, is_horizontal_split: bool, synced_zoom: bool, double_click_threshold_ms: u16, use_nearest_filter: bool, ) -> Element<'_, Message, WinitTheme, Renderer> { let first_img = panes[0].build_ui_container(use_slider_image_for_render, is_horizontal_split, double_click_threshold_ms, use_nearest_filter); let second_img = panes[1].build_ui_container(use_slider_image_for_render, is_horizontal_split, double_click_threshold_ms, use_nearest_filter); let is_selected: Vec = panes.iter().map(|pane| pane.is_selected).collect(); SyncedImageSplit::new( false, first_img, second_img, is_selected, divider_position, if is_horizontal_split { Axis::Horizontal } else { Axis::Vertical }, Message::OnSplitResize, Message::ResetSplit, Message::FileDropped, Message::PaneSelected, MENU_BAR_HEIGHT, true, ) .synced_zoom(synced_zoom) .min_scale(0.25) .max_scale(10.0) .scale_step(0.10) .double_click_threshold_ms(double_click_threshold_ms) .into() } pub fn build_ui_dual_pane_slider2<'a>( panes: &'a [Pane], divider_position: Option, show_footer: bool, use_slider_image_for_render: bool, is_horizontal_split: bool, _synced_zoom: bool, show_copy_buttons: bool, show_metadata: bool, double_click_threshold_ms: u16, footer_options: [FooterOptions; 2], use_nearest_filter: bool, use_binary_size: bool, spinner_location: SpinnerLocation, window_width: f32, ) -> Element<'a, Message, WinitTheme, Renderer> { // Each pane gets roughly half the window width let pane_width = window_width / 2.0; let footer_texts = [ format!( "{}/{}", panes[0].img_cache.current_index + 1, panes[0].img_cache.num_files ), format!( "{}/{}", panes[1].img_cache.current_index + 1, panes[1].img_cache.num_files ) ]; // Generate metadata text for each pane (EoG style) let metadata_texts = if show_metadata { [ panes[0].current_image_metadata.as_ref().map(|m| format!("{} pixels {}", m.resolution_string(), m.file_size_string(use_binary_size)) ), panes[1].current_image_metadata.as_ref().map(|m| format!("{} pixels {}", m.resolution_string(), m.file_size_string(use_binary_size)) ), ] } else { [None, None] }; // Destructure footer_options array let [footer_opt0, footer_opt1] = footer_options; // Show spinner after 1 second of loading let show_spinner_0 = panes[0].loading_started_at .is_some_and(|start| start.elapsed() > std::time::Duration::from_secs(1)); let show_spinner_1 = panes[1].loading_started_at .is_some_and(|start| start.elapsed() > std::time::Duration::from_secs(1)); let first_img = if panes[0].dir_loaded { container( if show_footer { column![ panes[0].build_ui_container(use_slider_image_for_render, is_horizontal_split, double_click_threshold_ms, use_nearest_filter), DualSlider::new( 0..=(panes[0].img_cache.num_files - 1) as u16, panes[0].slider_value, 0, Message::SliderChanged, Message::SliderReleased ) .width(Length::Fill), get_footer(footer_texts[0].clone(), metadata_texts[0].clone(), 0, show_copy_buttons, show_spinner_0, spinner_location, footer_opt0, pane_width) ] } else { column![ panes[0].build_ui_container(use_slider_image_for_render, is_horizontal_split, double_click_threshold_ms, use_nearest_filter), DualSlider::new( 0..=(panes[0].img_cache.num_files - 1) as u16, panes[0].slider_value, 0, Message::SliderChanged, Message::SliderReleased ) .width(Length::Fill), ] } ) } else { // Use build_ui_container even when dir not loaded to show loading spinner container(column![ panes[0].build_ui_container(use_slider_image_for_render, is_horizontal_split, double_click_threshold_ms, use_nearest_filter), ]) }; let second_img = if panes[1].dir_loaded { container( if show_footer { column![ panes[1].build_ui_container(use_slider_image_for_render, is_horizontal_split, double_click_threshold_ms, use_nearest_filter), DualSlider::new( 0..=(panes[1].img_cache.num_files - 1) as u16, panes[1].slider_value, 1, Message::SliderChanged, Message::SliderReleased ) .width(Length::Fill), get_footer(footer_texts[1].clone(), metadata_texts[1].clone(), 1, show_copy_buttons, show_spinner_1, spinner_location, footer_opt1, pane_width) ] } else { column![ panes[1].build_ui_container(use_slider_image_for_render, is_horizontal_split, double_click_threshold_ms, use_nearest_filter), DualSlider::new( 0..=(panes[1].img_cache.num_files - 1) as u16, panes[1].slider_value, 1, Message::SliderChanged, Message::SliderReleased ) .width(Length::Fill), ] } ) } else { // Use build_ui_container even when dir not loaded to show loading spinner container(column![ panes[1].build_ui_container(use_slider_image_for_render, is_horizontal_split, double_click_threshold_ms, use_nearest_filter), ]) }; let is_selected: Vec = panes.iter().map(|pane| pane.is_selected).collect(); SyncedImageSplit::new( true, first_img, second_img, is_selected, divider_position, if is_horizontal_split { Axis::Horizontal } else { Axis::Vertical }, Message::OnSplitResize, Message::ResetSplit, Message::FileDropped, Message::PaneSelected, MENU_BAR_HEIGHT, true, ) .synced_zoom(false) .min_scale(0.25) .max_scale(10.0) .scale_step(0.10) .double_click_threshold_ms(double_click_threshold_ms) .into() } fn get_fps_container(app: &DataViewer) -> Container<'_, Message, WinitTheme, Renderer> { // Get UI event loop FPS let ui_fps = { if let Ok(fps) = CURRENT_FPS.lock() { *fps } else { 0.0 } }; // Get image render FPS (image content refresh rate) // During slider movement use iced_wgpu::get_image_fps() // Otherwise use IMAGE_RENDER_FPS let image_fps = if app.use_slider_image_for_render { iced_wgpu::get_image_fps() } else { IMAGE_RENDER_FPS.lock().map(|fps| *fps as f64).unwrap_or(0.0) }; // Get memory usage in MB let memory_mb = { if let Ok(mem) = CURRENT_MEMORY_USAGE.lock() { if *mem == u64::MAX { // Special value indicating memory info is unavailable -1.0 // Use negative value as a marker } else { *mem as f64 / 1024.0 / 1024.0 } } else { 0.0 } }; if app.show_fps { // Use fixed-width number formatting to prevent spinner shifting let memory_text = if memory_mb < 0.0 { "Mem: N/A".to_string() } else { format!("Mem: {:6.1} MB", memory_mb) }; container( text(format!("UI: {:5.1} FPS | Image: {:5.1} FPS | {}", ui_fps, image_fps, memory_text)) .size(14) .font(Font::MONOSPACE) .style(|_theme| iced::widget::text::Style { color: Some(Color::from([1.0, 1.0, 1.0])) }) ) .padding(5) } else { container(text("")).width(0).height(0) } } ================================================ FILE: src/utils/mem.rs ================================================ use crate::CURRENT_MEMORY_USAGE; use crate::LAST_MEMORY_UPDATE; use std::time::Instant; #[allow(unused_imports)] use log::{debug, info, warn, error, trace}; // Import sysinfo for cross-platform memory tracking use sysinfo::{System, Pid, ProcessesToUpdate}; pub fn update_memory_usage() -> u64 { // Check if we should update (only once per second) let should_update = { if let Ok(last_update) = LAST_MEMORY_UPDATE.lock() { last_update.elapsed().as_secs() >= 1 } else { true } }; if !should_update { // Return current value if we're not updating return CURRENT_MEMORY_USAGE.lock().map(|mem| *mem).unwrap_or(0); } // Update the timestamp if let Ok(mut last_update) = LAST_MEMORY_UPDATE.lock() { *last_update = Instant::now(); } // Special handling for Apple platforms where sandboxed apps can't access process info #[cfg(any(target_os = "macos", target_os = "ios"))] { // Try to get memory info, but don't expect it to work in sandboxed environments let memory_used = match try_get_memory_usage() { Some(mem) => { // If it works, update the global memory tracking if let Ok(mut mem_lock) = CURRENT_MEMORY_USAGE.lock() { *mem_lock = mem; } mem }, None => { // Default to -1 as a marker that it's not available if let Ok(mut mem_lock) = CURRENT_MEMORY_USAGE.lock() { *mem_lock = u64::MAX; // Special value to indicate unavailable } u64::MAX } }; return memory_used; } // For non-Apple platforms, use the original implementation #[cfg(not(any(target_os = "macos", target_os = "ios")))] { // Use sysinfo for cross-platform memory tracking let mut system = System::new(); let pid = Pid::from_u32(std::process::id()); // Refresh specifically for this process system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); let memory_used = if let Some(process) = system.process(pid) { let memory = process.memory(); // Update the global memory tracking if let Ok(mut mem) = CURRENT_MEMORY_USAGE.lock() { *mem = memory; } memory } else { 0 }; trace!("Memory usage updated: {} bytes", memory_used); memory_used } } #[cfg(any(target_os = "macos", target_os = "ios"))] fn try_get_memory_usage() -> Option { // Try to get memory usage, but return None if it fails // This allows us to gracefully handle the sandboxed environment let mut system = System::new(); let pid = Pid::from_u32(std::process::id()); system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); system.process(pid).map(|process| process.memory()) } pub fn log_memory(label: &str) { // Use the unified memory tracking function let memory_bytes = update_memory_usage(); if memory_bytes == u64::MAX { info!("MEMORY [N/A] - {} (unavailable in sandbox)", label); } else { let memory_mb = memory_bytes as f64 / 1024.0 / 1024.0; info!("MEMORY [{:.2}MB] - {}", memory_mb, label); } } /// Check if system has enough memory for a large archive /// Returns (available_gb, recommended_proceed) pub fn check_memory_for_archive(archive_size_mb: u64) -> (f64, bool) { let mut system = System::new(); system.refresh_memory(); let available_bytes = system.available_memory(); let available_gb = available_bytes as f64 / 1024.0 / 1024.0 / 1024.0; // Recommend proceeding if available memory is at least 2x archive size let archive_gb = archive_size_mb as f64 / 1024.0; let recommended = available_gb > (archive_gb * 2.0); debug!("Memory check: Available {:.1}GB, Archive {:.1}GB, Recommended: {}", available_gb, archive_gb, recommended); (available_gb, recommended) } ================================================ FILE: src/utils/mod.rs ================================================ pub mod mem; pub mod save; pub mod timing; ================================================ FILE: src/utils/save.rs ================================================ use std::sync::Arc; use iced_wgpu::wgpu::{self, util::align_to, Texture}; use crate::app::DataViewer; pub(crate) fn extract_gpu_image(app: &mut DataViewer, texture: &Arc) -> Vec { let width = texture.width(); let height = texture.height(); let bytes_per_row = align_to(width * 4, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); let buffer = app.device.create_buffer(&wgpu::BufferDescriptor { label: Some("tmp"), size: (bytes_per_row * height) as u64, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); let mut encoder = app .device .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); encoder.copy_texture_to_buffer( texture.as_image_copy(), wgpu::ImageCopyBuffer { buffer: &buffer, layout: wgpu::ImageDataLayout { offset: 0, bytes_per_row: Some(bytes_per_row), rows_per_image: Some(height), }, }, texture.size(), ); app.queue.submit([encoder.finish()]); let (sender, receiver) = std::sync::mpsc::channel(); let buffer_slice = buffer.slice(..); buffer_slice.map_async(wgpu::MapMode::Read, move |result| { sender.send(result).unwrap(); }); app.device.poll(wgpu::Maintain::Wait); receiver.recv().unwrap().unwrap(); let padded_bytes_per_row = bytes_per_row as usize; let unpadded_bytes_per_row = (width * 4) as usize; let pixels: Vec = buffer_slice .get_mapped_range() .chunks(padded_bytes_per_row) .flat_map(|row| &row[..unpadded_bytes_per_row]) .copied() .collect(); buffer.unmap(); pixels } ================================================ FILE: src/utils/timing.rs ================================================ use std::time::{Duration, Instant}; #[allow(unused_imports)] use log::{debug, info}; pub struct TimingStats { pub name: String, pub total_time: Duration, pub count: u32, } impl TimingStats { pub fn new(name: &str) -> Self { Self { name: name.to_string(), total_time: Duration::from_secs(0), count: 0, } } pub fn add_measurement(&mut self, duration: Duration) { self.total_time += duration; self.count += 1; let avg_ms = self.average_ms(); info!("{} - Current: {:.2}ms, Avg: {:.2}ms, Count: {}", self.name, duration.as_secs_f64() * 1000.0, avg_ms, self.count ); } pub fn average_ms(&self) -> f64 { if self.count == 0 { 0.0 } else { (self.total_time.as_secs_f64() * 1000.0) / self.count as f64 } } } pub struct ScopedTimer<'a> { start: Instant, stats: &'a mut TimingStats, } impl<'a> ScopedTimer<'a> { #[allow(dead_code)] pub fn new(stats: &'a mut TimingStats) -> Self { Self { start: Instant::now(), stats, } } } impl<'a> Drop for ScopedTimer<'a> { fn drop(&mut self) { let duration = self.start.elapsed(); self.stats.add_measurement(duration); } } ================================================ FILE: src/widgets/circular.rs ================================================ //! Circular loading spinner widget that animates automatically. //! //! Implements Widget trait directly (not canvas::Program) so it can receive //! RedrawRequested events in on_event and clear cache for animation. use iced_winit::core::layout; use iced_winit::core::mouse; use iced_winit::core::renderer; use iced_winit::core::widget::tree::{self, Tree}; use iced_winit::core::{ Clipboard, Color, Element, Event, Layout, Length, Radians, Rectangle, Shell, Size, Vector, Widget, }; use iced_winit::core::Theme as WinitTheme; use iced_wgpu::Renderer; use iced_widget::canvas::{self, Path, Stroke}; use super::easing::{self, Easing}; use std::f32::consts::PI; use std::time::{Duration, Instant}; const MIN_ANGLE: Radians = Radians(PI / 8.0); const WRAP_ANGLE: Radians = Radians(2.0 * PI - PI / 4.0); /// Internal state for the Circular widget struct State { start_time: Instant, } impl Default for State { fn default() -> Self { Self { start_time: Instant::now(), } } } /// The circular spinner widget pub struct Circular<'a> { size: f32, bar_height: f32, easing: &'a Easing, cycle_duration: Duration, rotation_duration: Duration, track_color: Color, bar_color: Color, } impl<'a> Circular<'a> { pub fn new() -> Self { Self { size: 48.0, bar_height: 4.0, easing: &easing::EMPHASIZED, cycle_duration: Duration::from_secs(1), rotation_duration: Duration::from_secs(2), track_color: Color::from_rgba(1.0, 1.0, 1.0, 0.3), bar_color: Color::WHITE, } } /// Set the size of the spinner (default: 48.0) pub fn size(mut self, size: f32) -> Self { self.size = size; // Scale bar height proportionally (default is 4.0 for 48.0 size) self.bar_height = size / 12.0; self } } impl Default for Circular<'_> { fn default() -> Self { Self::new() } } impl<'a, Message> Widget for Circular<'a> where Message: 'a + Clone, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(State::default()) } fn size(&self) -> Size { Size { width: Length::Fixed(self.size), height: Length::Fixed(self.size), } } fn layout( &self, _tree: &mut Tree, _renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { layout::atomic(limits, self.size, self.size) } fn on_event( &mut self, _tree: &mut Tree, _event: Event, _layout: Layout<'_>, _cursor: mouse::Cursor, _renderer: &Renderer, _clipboard: &mut dyn Clipboard, _shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) -> iced_winit::core::event::Status { // Animation is driven by main loop's request_redraw when is_any_pane_loading() is true iced_winit::core::event::Status::Ignored } fn draw( &self, tree: &Tree, renderer: &mut Renderer, _theme: &WinitTheme, _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, _viewport: &Rectangle, ) { use iced_winit::core::Renderer as _; use iced_graphics::geometry::Renderer as _; let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); let now = Instant::now(); // Compute animation from wall clock time let elapsed = now.duration_since(state.start_time); let cycle_duration = self.cycle_duration.as_secs_f32(); let rotation_duration = self.rotation_duration.as_secs_f32(); // Total elapsed in cycles let total_cycles = elapsed.as_secs_f32() / cycle_duration; let cycle_index = total_cycles.floor() as u32; let progress_in_cycle = total_cycles.fract(); // Alternate between expanding (even) and contracting (odd) #[allow(clippy::manual_is_multiple_of)] let is_expanding = cycle_index % 2 == 0; let progress = progress_in_cycle; // Compute rotation let rotation = (elapsed.as_secs_f32() / rotation_duration).fract(); // Create frame and draw let mut frame = canvas::Frame::new(renderer, bounds.size()); let track_radius = frame.width() / 2.0 - self.bar_height; let track_path = Path::circle(frame.center(), track_radius); frame.stroke( &track_path, Stroke::default() .with_color(self.track_color) .with_width(self.bar_height), ); let mut builder = canvas::path::Builder::new(); let start = Radians(rotation * 2.0 * PI); if is_expanding { builder.arc(canvas::path::Arc { center: frame.center(), radius: track_radius, start_angle: start, end_angle: start + MIN_ANGLE + WRAP_ANGLE * self.easing.y_at_x(progress), }); } else { // Contracting: the start moves forward while end stays let wrap_progress = WRAP_ANGLE * self.easing.y_at_x(progress); builder.arc(canvas::path::Arc { center: frame.center(), radius: track_radius, start_angle: start + wrap_progress, end_angle: start + MIN_ANGLE + WRAP_ANGLE, }); } let bar_path = builder.build(); frame.stroke( &bar_path, Stroke::default() .with_color(self.bar_color) .with_width(self.bar_height), ); let geometry = frame.into_geometry(); renderer.with_translation(Vector::new(bounds.x, bounds.y), |renderer| { renderer.draw_geometry(geometry); }); } } impl<'a, Message> From> for Element<'a, Message, WinitTheme, Renderer> where Message: Clone + 'a, { fn from(circular: Circular<'a>) -> Self { Self::new(circular) } } /// Create a small circular spinner for compact spaces like footer (18px) pub fn mini_circular<'a, Message: Clone + 'a>() -> Element<'a, Message, WinitTheme, Renderer> { Circular::new().size(18.0).into() } ================================================ FILE: src/widgets/dualslider.rs ================================================ //! Sliders let users set a value by moving an indicator. //! //! # Example //! ```no_run //! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } //! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; //! # //! use iced::widget::slider; //! //! struct State { //! value: f32, //! } //! //! #[derive(Debug, Clone)] //! enum Message { //! ValueChanged(f32), //! } //! //! fn view(state: &State) -> Element<'_, Message> { //! slider(0.0..=100.0, state.value, Message::ValueChanged).into() //! } //! //! fn update(state: &mut State, message: Message) { //! match message { //! Message::ValueChanged(value) => { //! state.value = value; //! } //! } //! } //! ``` use iced_core::border::{self, Border}; use iced_core::keyboard; use iced_core::event; use iced_core::layout; use iced_core::mouse; use iced_core::renderer; use iced_core::touch; use iced_core::widget::tree::{self, Tree}; use iced_core::{ self, Background, Clipboard, Color, Element, Event, Layout, Length, Pixels, Point, Rectangle, Shell, Size, Theme, Widget, }; use std::ops::RangeInclusive; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; /// An horizontal bar and a handle that selects a single value from a range of /// values. /// /// A [`Slider`] will try to fill the horizontal space of its container. /// /// The [`Slider`] range of numeric values is generic and its step size defaults /// to 1 unit. /// /// # Example /// ```no_run /// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; /// # /// use iced::widget::slider; /// /// struct State { /// value: f32, /// } /// /// #[derive(Debug, Clone)] /// enum Message { /// ValueChanged(f32), /// } /// /// fn view(state: &State) -> Element<'_, Message> { /// slider(0.0..=100.0, state.value, Message::ValueChanged).into() /// } /// /// fn update(state: &mut State, message: Message) { /// match message { /// Message::ValueChanged(value) => { /// state.value = value; /// } /// } /// } /// ``` #[allow(missing_debug_implementations)] pub struct DualSlider<'a, T, Message, Theme = crate::Theme> where Theme: Catalog, { range: RangeInclusive, step: T, shift_step: Option, value: T, pane_index: isize, // needs to be isize because of the need to represent "all" panes; -1 default: Option, on_change: Box Message + 'a>, on_release: Box Message + 'a>, width: Length, height: f32, class: Theme::Class<'a>, } impl<'a, T, Message, Theme> DualSlider<'a, T, Message, Theme> where T: Copy + From + PartialOrd, Message: Clone, Theme: Catalog, { /// The default height of a [`Slider`]. //pub const DEFAULT_HEIGHT: f32 = 16.0; pub const DEFAULT_HEIGHT: f32 = 22.0; /// Creates a new [`Slider`]. /// /// It expects: /// * an inclusive range of possible values /// * the current value of the [`Slider`] /// * a function that will be called when the [`Slider`] is dragged. /// It receives the new value of the [`Slider`] and must produce a /// `Message`. pub fn new(range: RangeInclusive, value: T, pane_index: isize, on_change: F, on_release: G) -> Self where F: 'a + Fn(isize, T) -> Message, G: 'a + Fn(isize, T) -> Message, { let value = if value >= *range.start() { value } else { *range.start() }; let value = if value <= *range.end() { value } else { *range.end() }; DualSlider { value, default: None, range, pane_index, step: T::from(1), shift_step: None, on_change: Box::new(on_change), on_release: Box::new(on_release), width: Length::Fill, height: Self::DEFAULT_HEIGHT, class: Theme::default(), } } /// Sets the optional default value for the [`Slider`]. /// /// If set, the [`Slider`] will reset to this value when ctrl-clicked or command-clicked. pub fn default(mut self, default: impl Into) -> Self { self.default = Some(default.into()); self } /// Sets the release message of the [`Slider`]. /// This is called when the mouse is released from the slider. /// /// Typically, the user's interaction with the slider is finished when this message is produced. /// This is useful if you need to spawn a long-running task from the slider's result, where /// the default on_change message could create too many events. pub fn on_release(mut self, on_release: F) -> Self where F: 'a + Fn(isize, T) -> Message, { self.on_release = Box::new(on_release); self } /// Sets the width of the [`Slider`]. pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } /// Sets the height of the [`Slider`]. pub fn height(mut self, height: impl Into) -> Self { self.height = height.into().0; self } /// Sets the step size of the [`Slider`]. pub fn step(mut self, step: impl Into) -> Self { self.step = step.into(); self } /// Sets the optional "shift" step for the [`Slider`]. /// /// If set, this value is used as the step while the shift key is pressed. pub fn shift_step(mut self, shift_step: impl Into) -> Self { self.shift_step = Some(shift_step.into()); self } /// Sets the style of the [`Slider`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self where Theme::Class<'a>: From>, { self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); self } /// Sets the style class of the [`Slider`]. #[must_use] pub fn class(mut self, class: impl Into>) -> Self { self.class = class.into(); self } } impl<'a, T, Message, Theme, Renderer> Widget for DualSlider<'a, T, Message, Theme> where T: Copy + Into + num_traits::FromPrimitive, Message: Clone, Theme: Catalog, Renderer: renderer::Renderer, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(State::new()) } fn size(&self) -> Size { Size { width: self.width, height: Length::Shrink, } } fn layout( &self, _tree: &mut Tree, _renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { layout::atomic(limits, self.width, self.height) } fn on_event( &mut self, tree: &mut Tree, event: Event, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) -> event::Status { let state = tree.state.downcast_mut::(); let is_dragging = state.is_dragging; let current_value = self.value; let locate = |cursor_position: Point| -> Option { let bounds = layout.bounds(); let new_value = if cursor_position.x <= bounds.x { Some(*self.range.start()) } else if cursor_position.x >= bounds.x + bounds.width { Some(*self.range.end()) } else { let step = if state.keyboard_modifiers.shift() { self.shift_step.unwrap_or(self.step) } else { self.step } .into(); let start = (*self.range.start()).into(); let end = (*self.range.end()).into(); let percent = f64::from(cursor_position.x - bounds.x) / f64::from(bounds.width); let steps = (percent * (end - start) / step).round(); let value = steps * step + start; T::from_f64(value.min(end)) }; new_value }; let increment = |value: T| -> Option { let step = if state.keyboard_modifiers.shift() { self.shift_step.unwrap_or(self.step) } else { self.step } .into(); let steps = (value.into() / step).round(); let new_value = step * (steps + 1.0); if new_value > (*self.range.end()).into() { return Some(*self.range.end()); } T::from_f64(new_value) }; let decrement = |value: T| -> Option { let step = if state.keyboard_modifiers.shift() { self.shift_step.unwrap_or(self.step) } else { self.step } .into(); let steps = (value.into() / step).round(); let new_value = step * (steps - 1.0); if new_value < (*self.range.start()).into() { return Some(*self.range.start()); } T::from_f64(new_value) }; let change = |new_value: T| { if (self.value.into() - new_value.into()).abs() > f64::EPSILON { shell.publish((self.on_change)( self.pane_index, new_value )); self.value = new_value; } }; match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { if let Some(cursor_position) = cursor.position_over(layout.bounds()) { if state.keyboard_modifiers.command() { let _ = self.default.map(change); state.is_dragging = false; } else { //debug!("DS1 Mouse button pressed inner if block"); let _ = locate(cursor_position).map(change); state.is_dragging = true; } return event::Status::Captured; } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) | Event::Touch(touch::Event::FingerLost { .. }) => { if is_dragging { shell.publish((self.on_release)( self.pane_index, self.value )); state.is_dragging = false; return event::Status::Captured; } } Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) => { if is_dragging { let _ = cursor.position().and_then(locate).map(change); return event::Status::Captured; } } Event::Mouse(mouse::Event::WheelScrolled { delta }) if state.keyboard_modifiers.control() => { if cursor.is_over(layout.bounds()) { let delta = match delta { mouse::ScrollDelta::Lines { x: _, y } => y, mouse::ScrollDelta::Pixels { x: _, y } => y, }; if delta < 0.0 { let _ = decrement(current_value).map(change); } else { let _ = increment(current_value).map(change); } return event::Status::Captured; } } /*Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) => { if cursor.is_over(layout.bounds()) { match key { Key::Named(key::Named::ArrowUp) => { let _ = increment(current_value).map(change); } Key::Named(key::Named::ArrowDown) => { let _ = decrement(current_value).map(change); } _ => (), } return event::Status::Captured; } } Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { state.keyboard_modifiers = modifiers; }*/ _ => {} } event::Status::Ignored } fn draw( &self, tree: &Tree, renderer: &mut Renderer, theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, _viewport: &Rectangle, ) { let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); let is_mouse_over = cursor.is_over(bounds); let style = theme.style( &self.class, if state.is_dragging { Status::Dragged } else if is_mouse_over { Status::Hovered } else { Status::Active }, ); let (handle_width, handle_height, handle_border_radius) = match style.handle.shape { HandleShape::Circle { radius } => { (radius * 2.0, radius * 2.0, radius.into()) } HandleShape::Rectangle { width, border_radius, } => (f32::from(width), bounds.height, border_radius), }; let value = self.value.into() as f32; let (range_start, range_end) = { let (start, end) = self.range.clone().into_inner(); (start.into() as f32, end.into() as f32) }; let offset = if range_start >= range_end { 0.0 } else { (bounds.width - handle_width) * (value - range_start) / (range_end - range_start) }; let rail_y = bounds.y + bounds.height / 2.0; renderer.fill_quad( renderer::Quad { bounds: Rectangle { x: bounds.x, y: rail_y - style.rail.width / 2.0, width: offset + handle_width / 2.0, height: style.rail.width, }, border: style.rail.border, ..renderer::Quad::default() }, //style.rail.colors.0, style.rail.backgrounds.0, ); renderer.fill_quad( renderer::Quad { bounds: Rectangle { x: bounds.x + offset + handle_width / 2.0, y: rail_y - style.rail.width / 2.0, width: bounds.width - offset - handle_width / 2.0, height: style.rail.width, }, border: style.rail.border, ..renderer::Quad::default() }, //style.rail.colors.1, style.rail.backgrounds.1, ); renderer.fill_quad( renderer::Quad { bounds: Rectangle { x: bounds.x + offset, y: rail_y - handle_height / 2.0, width: handle_width, height: handle_height, }, border: Border { radius: handle_border_radius, width: style.handle.border_width, color: style.handle.border_color, }, ..renderer::Quad::default() }, style.handle.background, ); } fn mouse_interaction( &self, tree: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); let is_mouse_over = cursor.is_over(bounds); if state.is_dragging { mouse::Interaction::Grabbing } else if is_mouse_over { mouse::Interaction::Grab } else { mouse::Interaction::default() } } } impl<'a, T, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where T: Copy + Into + num_traits::FromPrimitive + 'a, Message: Clone + 'a, Theme: Catalog + 'a, //Renderer: core::Renderer + 'a, Renderer: renderer::Renderer + 'a, { fn from( slider: DualSlider<'a, T, Message, Theme>, ) -> Element<'a, Message, Theme, Renderer> { Element::new(slider) } } /// The possible status of a [`Slider`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { /// The [`Slider`] can be interacted with. Active, /// The [`Slider`] is being hovered. Hovered, /// The [`Slider`] is being dragged. Dragged, } /// The local state of a [`Slider`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct State { is_dragging: bool, keyboard_modifiers: keyboard::Modifiers, } impl State { /// Creates a new [`State`]. pub fn new() -> State { State::default() } } /// The appearance of a slider. #[derive(Debug, Clone, Copy)] pub struct Style { /// The colors of the rail of the slider. pub rail: Rail, /// The appearance of the [`Handle`] of the slider. pub handle: Handle, } impl Style { /// Changes the [`HandleShape`] of the [`Style`] to a circle /// with the given radius. pub fn with_circular_handle(mut self, radius: impl Into) -> Self { self.handle.shape = HandleShape::Circle { radius: radius.into().0, }; self } } /// The appearance of a slider rail #[derive(Debug, Clone, Copy)] pub struct Rail { /// The backgrounds of the rail of the slider. pub backgrounds: (Background, Background), /// The width of the stroke of a slider rail. pub width: f32, /// The border of the rail. pub border: Border, } /// The appearance of the handle of a slider. #[derive(Debug, Clone, Copy)] pub struct Handle { /// The shape of the handle. pub shape: HandleShape, /// The [`Background`] of the handle. pub background: Background, /// The border width of the handle. pub border_width: f32, /// The border [`Color`] of the handle. pub border_color: Color, } /// The shape of the handle of a slider. #[derive(Debug, Clone, Copy)] pub enum HandleShape { /// A circular handle. Circle { /// The radius of the circle. radius: f32, }, /// A rectangular shape. Rectangle { /// The width of the rectangle. width: u16, /// The border radius of the corners of the rectangle. border_radius: border::Radius, }, } /// The theme catalog of a [`Slider`]. pub trait Catalog: Sized { /// The item class of the [`Catalog`]. type Class<'a>; /// The default class produced by the [`Catalog`]. fn default<'a>() -> Self::Class<'a>; /// The [`Style`] of a class with the given status. fn style(&self, class: &Self::Class<'_>, status: Status) -> Style; } /// A styling function for a [`Slider`]. pub type StyleFn<'a, Theme> = Box Style + 'a>; impl Catalog for Theme { type Class<'a> = StyleFn<'a, Self>; fn default<'a>() -> Self::Class<'a> { Box::new(default) } fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { class(self, status) } } /// The default style of a [`Slider`]. pub fn default(theme: &Theme, status: Status) -> Style { let palette = theme.extended_palette(); let color = match status { Status::Active => palette.primary.strong.color, Status::Hovered => palette.primary.base.color, Status::Dragged => palette.primary.strong.color, }; Style { rail: Rail { backgrounds: (color.into(), palette.secondary.base.color.into()), width: 4.0, border: Border { radius: 2.0.into(), width: 0.0, color: Color::TRANSPARENT, }, }, handle: Handle { shape: HandleShape::Circle { radius: 7.0 }, background: color.into(), border_color: Color::TRANSPARENT, border_width: 0.0, }, } } ================================================ FILE: src/widgets/easing.rs ================================================ use iced_winit::core::Point; use lyon_algorithms::measure::PathMeasurements; use lyon_algorithms::path::{builder::NoAttributes, path::BuilderImpl, Path}; use once_cell::sync::Lazy; pub static EMPHASIZED: Lazy = Lazy::new(|| { Easing::builder() .cubic_bezier_to([0.05, 0.0], [0.133333, 0.06], [0.166666, 0.4]) .cubic_bezier_to([0.208333, 0.82], [0.25, 1.0], [1.0, 1.0]) .build() }); pub struct Easing { path: Path, measurements: PathMeasurements, } impl Easing { pub fn builder() -> Builder { Builder::new() } pub fn y_at_x(&self, x: f32) -> f32 { let mut sampler = self.measurements.create_sampler( &self.path, lyon_algorithms::measure::SampleType::Normalized, ); let sample = sampler.sample(x); sample.position().y } } pub struct Builder(NoAttributes); impl Builder { pub fn new() -> Self { let mut builder = Path::builder(); builder.begin(lyon_algorithms::geom::point(0.0, 0.0)); Self(builder) } /// Adds a cubic bézier curve. Points must be between 0,0 and 1,1 pub fn cubic_bezier_to( mut self, ctrl1: impl Into, ctrl2: impl Into, to: impl Into, ) -> Self { self.0.cubic_bezier_to( Self::point(ctrl1), Self::point(ctrl2), Self::point(to), ); self } pub fn build(mut self) -> Easing { self.0.line_to(lyon_algorithms::geom::point(1.0, 1.0)); self.0.end(false); let path = self.0.build(); let measurements = PathMeasurements::from_path(&path, 0.0); Easing { path, measurements } } fn point(p: impl Into) -> lyon_algorithms::geom::Point { let p: Point = p.into(); lyon_algorithms::geom::point(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)) } } impl Default for Builder { fn default() -> Self { Self::new() } } ================================================ FILE: src/widgets/mod.rs ================================================ pub mod split; pub mod dualslider; pub mod toggler; pub mod viewer; pub mod modal; pub mod shader; pub mod synced_image_split; pub mod easing; pub mod circular; #[cfg(feature = "selection")] pub mod selection_widget; ================================================ FILE: src/widgets/modal.rs ================================================ use iced_winit::core::{ Color, Element }; use iced_widget::{container, stack, mouse_area, center, opaque}; use iced_wgpu::Renderer; use iced_winit::core::Theme as WinitTheme; pub fn modal<'a, Message>( base: impl Into>, content: impl Into>, on_blur: Message, ) -> Element<'a, Message, WinitTheme, Renderer> where Message: Clone + 'a, { stack![ base.into(), opaque( mouse_area(center(opaque(content)).style(|_theme| { container::Style { background: Some( Color { a: 0.8, ..Color::BLACK } .into(), ), ..container::Style::default() } })) .on_press(on_blur) ) ] .into() } ================================================ FILE: src/widgets/selection_widget.rs ================================================ /// Image selection and curation widget for dataset preparation /// /// This module is only compiled when the "selection" feature is enabled. /// It encapsulates all selection-related messages and UI components. use std::path::PathBuf; use iced_winit::core::{Element, Color}; use iced_winit::core::Theme as WinitTheme; use iced_winit::runtime::Task; use iced_wgpu::Renderer; use iced_widget::{container, text}; use iced_core::padding; use iced_core::keyboard::{self, Key}; use log::{info, error}; use crate::app::Message; use crate::selection_manager::{ImageMark, SelectionManager}; use crate::pane::Pane; use crate::menu::PaneLayout; /// Selection-specific messages grouped into a single enum variant #[derive(Debug, Clone)] pub enum SelectionMessage { MarkImageSelected(usize), // pane_index MarkImageExcluded(usize), // pane_index ClearImageMark(usize), // pane_index ExportSelectionJson, ExportSelectionJsonToPath(PathBuf), } /// Convert SelectionMessage to the main Message type impl From for Message { fn from(msg: SelectionMessage) -> Self { Message::SelectionAction(msg) } } /// Creates a badge widget showing the image's mark status pub fn mark_badge(mark: ImageMark) -> Element<'static, Message, WinitTheme, Renderer> { match mark { ImageMark::Selected => container( text("SELECTED") .size(12) .style(|_theme| iced_widget::text::Style { color: Some(Color::from([1.0, 1.0, 1.0])) }) ) .padding(padding::all(4)) .style(|_theme: &WinitTheme| container::Style { background: Some(Color::from([0.2, 0.8, 0.2]).into()), // Green border: iced_winit::core::Border { radius: 4.0.into(), width: 0.0, color: Color::TRANSPARENT, }, ..container::Style::default() }) .into(), ImageMark::Excluded => container( text("EXCLUDED") .size(12) .style(|_theme| iced_widget::text::Style { color: Some(Color::from([1.0, 1.0, 1.0])) }) ) .padding(padding::all(4)) .style(|_theme: &WinitTheme| container::Style { background: Some(Color::from([0.9, 0.2, 0.2]).into()), // Red border: iced_winit::core::Border { radius: 4.0.into(), width: 0.0, color: Color::TRANSPARENT, }, ..container::Style::default() }) .into(), ImageMark::Unmarked => container(text("")) .width(0) .height(0) .into(), } } /// Empty badge for when ML features are disabled pub fn empty_badge() -> Element<'static, Message, WinitTheme, Renderer> { container(text("")).width(0).height(0).into() } /// Handle selection messages by delegating to the selection manager /// /// This function encapsulates all selection-related message handling logic, /// keeping it separate from the main app.rs update loop. pub fn handle_selection_message( msg: SelectionMessage, panes: &[Pane], selection_manager: &mut SelectionManager, ) -> Task { match msg { SelectionMessage::MarkImageSelected(pane_index) => { if let Some(pane) = panes.get(pane_index) { if pane.dir_loaded { let path = &pane.img_cache.image_paths[pane.img_cache.current_index]; let filename = path.file_name().to_string(); selection_manager.toggle_selected(&filename); info!("Toggled selected: {}", filename); // Save immediately if let Err(e) = selection_manager.save() { error!("Failed to save selection state: {}", e); } } } Task::none() } SelectionMessage::MarkImageExcluded(pane_index) => { if let Some(pane) = panes.get(pane_index) { if pane.dir_loaded { let path = &pane.img_cache.image_paths[pane.img_cache.current_index]; let filename = path.file_name().to_string(); selection_manager.toggle_excluded(&filename); info!("Toggled excluded: {}", filename); // Save immediately if let Err(e) = selection_manager.save() { error!("Failed to save selection state: {}", e); } } } Task::none() } SelectionMessage::ClearImageMark(pane_index) => { if let Some(pane) = panes.get(pane_index) { if pane.dir_loaded { let path = &pane.img_cache.image_paths[pane.img_cache.current_index]; let filename = path.file_name().to_string(); selection_manager.clear_mark(&filename); info!("Cleared mark: {}", filename); // Save immediately if let Err(e) = selection_manager.save() { error!("Failed to save selection state: {}", e); } } } Task::none() } SelectionMessage::ExportSelectionJson => { // Use file picker to choose export location Task::perform( async { rfd::AsyncFileDialog::new() .set_file_name("selections.json") .add_filter("JSON", &["json"]) .save_file() .await }, |file_handle| { if let Some(file) = file_handle { let path = file.path().to_path_buf(); Message::SelectionAction(SelectionMessage::ExportSelectionJsonToPath(path)) } else { Message::Nothing } } ) } SelectionMessage::ExportSelectionJsonToPath(path) => { info!("Exporting selection to: {}", path.display()); if let Err(e) = selection_manager.export_to_file(&path) { error!("Failed to export selection: {}", e); } else { info!("Successfully exported selections to: {}", path.display()); } Task::none() } } } /// Handle selection-related keyboard events /// /// Returns Some(Task) if the key was handled, None if not a selection key pub fn handle_keyboard_event( key: &keyboard::Key, modifiers: keyboard::Modifiers, pane_layout: &PaneLayout, last_opened_pane: isize, ) -> Option> { // Helper to determine current pane index let get_pane_index = || { if *pane_layout == PaneLayout::SinglePane { 0 } else { last_opened_pane as usize } }; // Helper for platform-specific modifier key let is_platform_modifier = || { #[cfg(target_os = "macos")] return modifiers.logo(); // Command key on macOS #[cfg(not(target_os = "macos"))] return modifiers.control(); // Control key on other platforms }; match key.as_ref() { Key::Character("s") | Key::Character("S") => { let pane_index = get_pane_index(); Some(Task::done(Message::SelectionAction( SelectionMessage::MarkImageSelected(pane_index) ))) } Key::Character("x") | Key::Character("X") => { let pane_index = get_pane_index(); Some(Task::done(Message::SelectionAction( SelectionMessage::MarkImageExcluded(pane_index) ))) } Key::Character("u") | Key::Character("U") => { let pane_index = get_pane_index(); Some(Task::done(Message::SelectionAction( SelectionMessage::ClearImageMark(pane_index) ))) } Key::Character("e") | Key::Character("E") => { if is_platform_modifier() { Some(Task::done(Message::SelectionAction( SelectionMessage::ExportSelectionJson ))) } else { None } } _ => None } } ================================================ FILE: src/widgets/shader/atlas_texture.wgsl ================================================ struct VertexOutput { @builtin(position) position: vec4, @location(0) tex_coords: vec2, }; struct Uniforms { atlas_coords: vec4, // x, y, width, height in atlas layer: f32, // atlas layer image_size: vec2, // original image dimensions _padding: f32, }; @group(0) @binding(0) var t_atlas: texture_2d_array; @group(0) @binding(1) var s_atlas: sampler; @group(0) @binding(2) var uniforms: Uniforms; @vertex fn vs_main( @location(0) position: vec2, @location(1) tex_coords: vec2, ) -> VertexOutput { var out: VertexOutput; out.position = vec4(position, 0.0, 1.0); out.tex_coords = tex_coords; return out; } @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Transform texture coordinates to atlas coordinates let atlas_x = uniforms.atlas_coords.x + in.tex_coords.x * uniforms.atlas_coords.z; let atlas_y = uniforms.atlas_coords.y + in.tex_coords.y * uniforms.atlas_coords.w; // Sample from the correct layer let color = textureSample( t_atlas, s_atlas, vec2(atlas_x, atlas_y), i32(uniforms.layer) ); return color; } ================================================ FILE: src/widgets/shader/cpu_scene.rs ================================================ #[allow(unused_imports)] use log::{debug, info, warn, error}; use std::sync::Arc; use std::time::Instant; use std::collections::HashMap; use std::sync::Mutex; use once_cell::sync::Lazy; use image::GenericImageView; use iced_widget::shader::{self, Viewport}; use iced_winit::core::{Rectangle, mouse}; use iced_wgpu::wgpu; use crate::utils::timing::TimingStats; use crate::widgets::shader::texture_pipeline::TexturePipeline; use crate::cache::texture_cache::TextureCache; static _SHADER_UPDATE_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("CPU Shader Update")) }); // Change from a single global cache to a map of pane-specific caches static TEXTURE_CACHES: Lazy>> = Lazy::new(|| { Mutex::new(HashMap::new()) }); #[derive(Debug, Default)] pub struct CpuPipelineRegistry { pipelines: std::collections::HashMap, } #[derive(Debug, Clone)] pub struct CpuScene { pub image_bytes: Vec, // Store CPU image bytes pub texture: Option>, // Lazily created GPU texture pub texture_size: (u32, u32), // Image dimensions pub needs_update: bool, // Flag to indicate if texture needs updating pub use_cached_texture: bool, // Flag to indicate if cached texture should be used } impl CpuScene { pub fn new(image_bytes: Vec, use_cached_texture: bool) -> Self { // Check if image_bytes is empty before attempting to load let dimensions = if !image_bytes.is_empty() { match crate::exif_utils::decode_with_exif_orientation(&image_bytes) { Ok(img) => { let (width, height) = img.dimensions(); debug!("CpuScene::new - loaded image with dimensions: {}x{}", width, height); (width, height) }, Err(e) => { error!("CpuScene::new - Failed to load image dimensions: {:?}", e); (0, 0) // Default to 0,0 if we can't determine dimensions } } } else { // No image data provided, use default dimensions debug!("CpuScene::new - No image data provided, using default dimensions"); (0, 0) }; CpuScene { image_bytes, texture: None, texture_size: dimensions, needs_update: true, use_cached_texture, } } pub fn update_image(&mut self, new_image_bytes: Vec) { // Update image bytes and mark texture for recreation self.image_bytes = new_image_bytes; // Attempt to update dimensions from the new image bytes if let Ok(img) = crate::exif_utils::decode_with_exif_orientation(&self.image_bytes) { self.texture_size = img.dimensions(); } self.needs_update = true; self.texture = None; // Force texture recreation } // Create GPU texture from CPU bytes - expose as public pub fn ensure_texture(&mut self, device: &Arc, queue: &Arc, pane_id: &str) -> Option> { if self.needs_update || self.texture.is_none() { let start = Instant::now(); debug!("CpuScene::ensure_texture - Using cached or creating texture from {} bytes for pane {}", self.image_bytes.len(), pane_id); // Validate image data before attempting to create texture if self.image_bytes.is_empty() { error!("CpuScene::ensure_texture - Empty image data, cannot create texture"); return None; } if self.use_cached_texture { let cache_start = Instant::now(); if let Ok(mut caches) = TEXTURE_CACHES.lock() { let cache_lock_time = cache_start.elapsed(); debug!("CpuScene::ensure_texture - Acquired texture caches lock in {:?}", cache_lock_time); // Get or create the cache for this specific pane let cache = caches.entry(pane_id.to_string()) .or_insert_with(TextureCache::new); let texture_start = Instant::now(); if let Some(texture) = cache.get_or_create_texture( device, queue, &self.image_bytes, self.texture_size ) { let texture_time = texture_start.elapsed(); debug!("CpuScene::ensure_texture - get_or_create_texture took {:?} for pane {}", texture_time, pane_id); self.texture = Some(Arc::clone(&texture)); self.needs_update = false; let total_time = start.elapsed(); debug!("CpuScene::ensure_texture - Total time: {:?} for pane {}", total_time, pane_id); return Some(Arc::clone(&texture)); } } // If we failed to get/create a texture from the cache, fallback to direct creation error!("Failed to get/create texture from cache for pane {}", pane_id); } // Direct texture creation (fallback or when cache is disabled) let texture_start = Instant::now(); match crate::exif_utils::decode_with_exif_orientation(&self.image_bytes) { Ok(img) => { let rgba = img.to_rgba8(); let dimensions = img.dimensions(); if dimensions.0 == 0 || dimensions.1 == 0 { error!("CpuScene::ensure_texture - Invalid image dimensions: {}x{}", dimensions.0, dimensions.1); return None; } debug!("CpuScene::ensure_texture - Creating texture with dimensions {}x{}", dimensions.0, dimensions.1); let texture = device.create_texture( &wgpu::TextureDescriptor { label: Some("CpuScene Texture"), size: wgpu::Extent3d { width: dimensions.0, height: dimensions.1, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8UnormSrgb, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], } ); queue.write_texture( wgpu::ImageCopyTexture { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, &rgba, wgpu::ImageDataLayout { offset: 0, bytes_per_row: Some(4 * dimensions.0), rows_per_image: Some(dimensions.1), }, wgpu::Extent3d { width: dimensions.0, height: dimensions.1, depth_or_array_layers: 1, }, ); let texture_arc = Arc::new(texture); self.texture = Some(Arc::clone(&texture_arc)); self.needs_update = false; let creation_time = texture_start.elapsed(); debug!("Created texture directly in {:?}", creation_time); return Some(texture_arc); }, Err(e) => { error!("CpuScene::ensure_texture - Failed to load image: {:?}", e); return None; } } } if self.texture.is_none() { warn!("CpuScene::ensure_texture - No texture available after ensure_texture call"); } self.texture.clone() } } #[allow(dead_code)] #[derive(Debug, Clone)] pub struct CpuPrimitive { image_bytes: Vec, texture: Option>, texture_size: (u32, u32), bounds: Rectangle, needs_update: bool, } impl CpuPrimitive { pub fn new( image_bytes: Vec, texture: Option>, texture_size: (u32, u32), bounds: Rectangle, needs_update: bool, ) -> Self { Self { image_bytes, texture, texture_size, bounds, needs_update, } } } impl shader::Primitive for CpuPrimitive { fn prepare( &self, device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat, storage: &mut shader::Storage, bounds: &Rectangle, viewport: &Viewport, ) { let debug = false; let prepare_start = Instant::now(); let scale_factor = viewport.scale_factor() as f32; let viewport_size = viewport.physical_size(); let shader_size = ( (bounds.width * scale_factor) as u32, (bounds.height * scale_factor) as u32, ); let bounds_relative = ( (bounds.x * scale_factor) / viewport_size.width as f32, (bounds.y * scale_factor) / viewport_size.height as f32, (bounds.width * scale_factor) / viewport_size.width as f32, (bounds.height * scale_factor) / viewport_size.height as f32, ); // Create a unique key for this pipeline based on position let pipeline_key = format!("cpu_pipeline_{}_{}_{}_{}", bounds.x, bounds.y, bounds.width, bounds.height); // Only proceed if we have a valid texture if let Some(texture) = &self.texture { // Ensure we have a registry if !storage.has::() { storage.store(CpuPipelineRegistry::default()); } // Get the registry let registry = storage.get_mut::().unwrap(); // Check if we need to create a new pipeline for this position if !registry.pipelines.contains_key(&pipeline_key) { let pipeline = TexturePipeline::new( device, queue, format, texture.clone(), shader_size, self.texture_size, bounds_relative, false, // Default to Linear filter for CPU scene renderer ); registry.pipelines.insert(pipeline_key.clone(), pipeline); } else { let pipeline = registry.pipelines.get_mut(&pipeline_key).unwrap(); let vertices_start = Instant::now(); pipeline.update_vertices(device, bounds_relative); let _vertices_time = vertices_start.elapsed(); let texture_update_start = Instant::now(); pipeline.update_texture(device, queue, texture.clone(), false); let _texture_update_time = texture_update_start.elapsed(); let uniforms_start = Instant::now(); pipeline.update_screen_uniforms(queue, self.texture_size, shader_size, bounds_relative); let _uniforms_time = uniforms_start.elapsed(); } } else { warn!("No texture available for rendering"); } let prepare_time = prepare_start.elapsed(); if debug { debug!("CpuPrimitive prepare - bounds: {:?}, bounds_relative: {:?}", bounds, bounds_relative); debug!("CpuPrimitive prepare - viewport_size: {:?}, shader_size: {:?}", viewport_size, shader_size); debug!("CpuPrimitive prepare completed in {:?}", prepare_time); } } fn render( &self, encoder: &mut wgpu::CommandEncoder, storage: &shader::Storage, target: &wgpu::TextureView, clip_bounds: &Rectangle, ) { let render_start = Instant::now(); if self.texture.is_some() { // Get the pipeline key for this position let pipeline_key = format!("cpu_pipeline_{}_{}_{}_{}", self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height); // Find our pipeline in the registry if let Some(registry) = storage.get::() { if let Some(pipeline) = registry.pipelines.get(&pipeline_key) { debug!("Rendering CPU image with TexturePipeline for key {}", pipeline_key); pipeline.render(target, encoder, clip_bounds); let render_time = render_start.elapsed(); debug!("Rendered CPU image in {:?}", render_time); } else { warn!("TexturePipeline not found in registry with key {}", pipeline_key); } } else { warn!("CpuPipelineRegistry not found in storage"); } } else { warn!("Cannot render - no texture available"); } } } impl shader::Program for CpuScene { type State = (); type Primitive = CpuPrimitive; fn draw( &self, _state: &Self::State, _cursor: mouse::Cursor, bounds: Rectangle, ) -> Self::Primitive { CpuPrimitive::new( self.image_bytes.clone(), self.texture.clone(), self.texture_size, bounds, self.needs_update, ) } } ================================================ FILE: src/widgets/shader/image_shader.rs ================================================ #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; use std::marker::PhantomData; use std::sync::Arc; use iced_core::ContentFit; use iced_core::{Vector, Point}; use iced_core::layout::Layout; use iced_core::clipboard::Clipboard; use iced_core::event; use iced_winit::core::{self, layout, mouse, renderer, widget::{self, tree::{self, Tree}}, Element, Length, Rectangle, Shell, Size}; use iced_widget::shader::{self, Viewport, Storage}; use iced_wgpu::{wgpu, primitive}; use crate::widgets::shader::texture_pipeline::TexturePipeline; use crate::Scene; use std::collections::HashMap; use std::collections::VecDeque; use crate::widgets::split::DIVIDER_HITBOX_EXPANSION; use crate::CONFIG; /// A specialized shader widget for displaying images with proper aspect ratio. pub struct ImageShader { width: Length, height: Length, scene: Option, content_fit: ContentFit, min_scale: f32, max_scale: f32, scale_step: f32, double_click_threshold_ms: u16, _phantom: PhantomData, debug: bool, is_horizontal_split: bool, mouse_wheel_zoom: bool, ctrl_pressed: bool, #[cfg(feature = "coco")] pane_index: usize, #[cfg(feature = "coco")] on_zoom_change: Option Message>>, #[cfg(feature = "coco")] image_index: usize, initial_scale: Option, initial_offset: Option, use_nearest_filter: bool, } impl ImageShader { /// Create a new ImageShader widget that works with Scene pub fn new(scene: Option<&Scene>) -> Self { // Clone the Scene if it exists let scene_clone = scene.cloned(); let debug = false; // Add debug output to track scene creation if debug && scene.is_some() { debug!("ImageShader::new - Created with a scene"); if let Some(s) = scene { if s.get_texture().is_some() { debug!("ImageShader::new - Scene has a texture"); } else { debug!("ImageShader::new - Scene has NO texture!"); } } } else if debug { debug!("ImageShader::new - Created with NO scene"); } Self { width: Length::Fill, height: Length::Fill, scene: scene_clone, content_fit: ContentFit::Contain, min_scale: 0.25, max_scale: 10.0, scale_step: 0.10, double_click_threshold_ms: CONFIG.double_click_threshold_ms, _phantom: PhantomData, debug, is_horizontal_split: false, mouse_wheel_zoom: false, ctrl_pressed: false, #[cfg(feature = "coco")] pane_index: 0, #[cfg(feature = "coco")] on_zoom_change: None, #[cfg(feature = "coco")] image_index: 0, initial_scale: None, initial_offset: None, use_nearest_filter: false, } } /// Sets the initial zoom scale and pan offset for the [`ImageShader`]. /// This is useful for preserving zoom state during navigation. pub fn with_zoom_state(mut self, scale: f32, offset: Vector) -> Self { self.initial_scale = Some(scale); self.initial_offset = Some(offset); self } /// Set the width of the widget pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } /// Set the height of the widget pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } /// Set how the image should fit within the widget bounds pub fn content_fit(mut self, content_fit: ContentFit) -> Self { self.content_fit = content_fit; self } /// Update the scene pub fn update_scene(&mut self, new_scene: Scene) { debug!("ImageShader::update_scene - Updating scene"); self.scene = Some(new_scene); } /// Sets the max scale applied to the image. /// /// Default is `10.0` pub fn max_scale(mut self, max_scale: f32) -> Self { self.max_scale = max_scale; self } /// Sets the min scale applied to the image. /// /// Default is `0.25` pub fn min_scale(mut self, min_scale: f32) -> Self { self.min_scale = min_scale; self } /// Sets the percentage the image will be scaled by when zoomed in / out. /// /// Default is `0.10` pub fn scale_step(mut self, scale_step: f32) -> Self { self.scale_step = scale_step; self } /// Sets the double-click threshold in milliseconds. /// /// Default is `250` pub fn double_click_threshold_ms(mut self, threshold_ms: u16) -> Self { self.double_click_threshold_ms = threshold_ms; self } /// Calculate the layout bounds that preserve aspect ratio fn _calculate_layout(&self, bounds: Rectangle) -> Rectangle { if let Some(ref scene) = self.scene { if let Some(texture) = scene.get_texture() { debug!("ImageShader::calculate_layout - Got texture {}x{}", texture.width(), texture.height()); let texture_size = Size::new(texture.width() as f32, texture.height() as f32); let bounds_size = bounds.size(); // Calculate image size based on content fit let (width, height) = match self.content_fit { ContentFit::Fill => (bounds_size.width, bounds_size.height), ContentFit::Contain => { let width_ratio = bounds_size.width / texture_size.width; let height_ratio = bounds_size.height / texture_size.height; let ratio = width_ratio.min(height_ratio); (texture_size.width * ratio, texture_size.height * ratio) }, ContentFit::Cover => { let width_ratio = bounds_size.width / texture_size.width; let height_ratio = bounds_size.height / texture_size.height; let ratio = width_ratio.max(height_ratio); (texture_size.width * ratio, texture_size.height * ratio) }, ContentFit::ScaleDown => { let width_ratio = bounds_size.width / texture_size.width; let height_ratio = bounds_size.height / texture_size.height; let ratio = width_ratio.min(height_ratio).min(1.0); (texture_size.width * ratio, texture_size.height * ratio) }, ContentFit::None => (texture_size.width, texture_size.height), }; // Calculate image position to center it let diff_w = bounds_size.width - width; let diff_h = bounds_size.height - height; let x = bounds.x + diff_w / 2.0; let y = bounds.y + diff_h / 2.0; // NEW: Apply 1px padding on all sides to avoid border overlap let padding = 1.0; let padded_rect = Rectangle { x: x + padding, y: y + padding, width: width - 2.0 * padding, height: height - 2.0 * padding, }; debug!("ImageShader::calculate_layout - Calculated content bounds: ({}, {}, {}, {})", padded_rect.x, padded_rect.y, padded_rect.width, padded_rect.height); return padded_rect; } else { debug!("ImageShader::calculate_layout - Scene has NO texture!"); } } else { debug!("ImageShader::calculate_layout - No scene available"); } // Fallback to original bounds if no texture bounds } /// Set the horizontal split flag pub fn horizontal_split(mut self, is_horizontal: bool) -> Self { self.is_horizontal_split = is_horizontal; self } } // Expanded ImageShaderState to track zoom and pan #[derive(Debug, Clone, Copy, Default)] pub struct ImageShaderState { pub scale: f32, pub starting_offset: Vector, pub current_offset: Vector, pub cursor_grabbed_at: Option, pub last_click_time: Option, #[allow(dead_code)] pub last_image_index: usize, // Track image index to detect image changes } impl ImageShaderState { pub fn new() -> Self { Self { scale: 1.0, starting_offset: Vector::default(), current_offset: Vector::default(), cursor_grabbed_at: None, last_click_time: None, last_image_index: 0, } } /// Returns if the cursor is currently grabbed pub fn is_cursor_grabbed(&self) -> bool { self.cursor_grabbed_at.is_some() } /// Returns the current offset, clamped to prevent image from going too far off-screen fn offset(&self, bounds: Rectangle, image_size: Size) -> Vector { let hidden_width = (image_size.width - bounds.width / 2.0).max(0.0).round(); let hidden_height = (image_size.height - bounds.height / 2.0).max(0.0).round(); Vector::new( self.current_offset.x.clamp(-hidden_width, hidden_width), self.current_offset.y.clamp(-hidden_height, hidden_height), ) } } // This is our specialized primitive for image rendering #[allow(dead_code)] #[derive(Debug)] pub struct ImagePrimitive { scene: Scene, bounds: Rectangle, content_bounds: Rectangle, scale: f32, offset: Vector, debug: bool, use_nearest_filter: bool, } impl shader::Primitive for ImagePrimitive { fn prepare( &self, device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat, storage: &mut Storage, _bounds: &Rectangle, viewport: &Viewport, ) { // Make sure the viewport is stored in storage for later use in render storage.store(viewport.clone()); let scale_factor = viewport.scale_factor() as f32; let viewport_size = viewport.physical_size(); if self.debug { debug!("ImagePrimitive::prepare - Starting prepare"); debug!("ImagePrimitive::prepare - Content bounds: {:?}", self.content_bounds); debug!("ImagePrimitive::prepare - Viewport: {:?}, scale: {}", viewport_size, scale_factor); } // Get texture from scene if let Some(texture) = self.scene.get_texture() { if self.debug { debug!("ImagePrimitive::prepare - Got texture {}x{}", texture.width(), texture.height()); } let texture_size = (texture.width(), texture.height()); // Calculate normalized device coordinates for viewport let x_rel = self.content_bounds.x * scale_factor / viewport_size.width as f32; let y_rel = self.content_bounds.y * scale_factor / viewport_size.height as f32; let width_rel = self.content_bounds.width * scale_factor / viewport_size.width as f32; let height_rel = self.content_bounds.height * scale_factor / viewport_size.height as f32; let bounds_relative = (x_rel, y_rel, width_rel, height_rel); if self.debug { debug!("ImagePrimitive::prepare - Relative bounds: {:?}", bounds_relative); } // Create a unique pipeline key based on bounds and filter mode let pipeline_key = format!("img_pipeline_{:.4}_{:.4}_{:.4}_{:.4}_{}", bounds_relative.0, bounds_relative.1, bounds_relative.2, bounds_relative.3, if self.use_nearest_filter { "nearest" } else { "linear" }); // Ensure we have a registry to store pipelines if !storage.has::() { storage.store(PipelineRegistry::default()); } let registry = storage.get_mut::().unwrap(); // Create pipeline if it doesn't exist or reuse existing one if !registry.contains_key(&pipeline_key) { debug!("ImagePrimitive::prepare - Creating NEW pipeline for key {} with use_nearest_filter={}", pipeline_key, self.use_nearest_filter); let pipeline = TexturePipeline::new( device, queue, format, Arc::clone(texture), (viewport_size.width, viewport_size.height), texture_size, bounds_relative, self.use_nearest_filter, ); registry.insert(pipeline_key.clone(), pipeline); if self.debug { debug!("ImagePrimitive::prepare - Pipeline created and stored"); } } else { debug!("ImagePrimitive::prepare - REUSING existing pipeline for key {}", pipeline_key); // Update the texture in the existing pipeline if let Some(pipeline) = registry.get_mut(&pipeline_key) { if self.debug { debug!("ImagePrimitive::prepare - Updating texture in existing pipeline"); } pipeline.update_texture(device, queue, Arc::clone(texture), self.use_nearest_filter); } } } else { debug!("ImagePrimitive::prepare - Scene has NO texture!"); } } fn render( &self, encoder: &mut wgpu::CommandEncoder, storage: &Storage, target: &wgpu::TextureView, clip_bounds: &Rectangle, ) { // Get texture from scene if let Some(texture) = self.scene.get_texture() { if self.debug { debug!("ImagePrimitive::render - Got texture {}x{}", texture.width(), texture.height()); } // Access the pipeline registry if let Some(registry) = storage.get::() { // Store the viewport in prepare and retrieve it here if let Some(viewport) = storage.get::() { // Same code as before to calculate the key let scale_factor = viewport.scale_factor() as f32; let viewport_size = viewport.physical_size(); let x_rel = self.content_bounds.x * scale_factor / viewport_size.width as f32; let y_rel = self.content_bounds.y * scale_factor / viewport_size.height as f32; let width_rel = self.content_bounds.width * scale_factor / viewport_size.width as f32; let height_rel = self.content_bounds.height * scale_factor / viewport_size.height as f32; let bounds_relative = (x_rel, y_rel, width_rel, height_rel); let pipeline_key = format!("img_pipeline_{:.4}_{:.4}_{:.4}_{:.4}_{}", bounds_relative.0, bounds_relative.1, bounds_relative.2, bounds_relative.3, if self.use_nearest_filter { "nearest" } else { "linear" }); if let Some(pipeline) = registry.get_ref(&pipeline_key) { pipeline.render(target, encoder, clip_bounds); } else { debug!("ImagePrimitive::render - Pipeline NOT found for key: {}", pipeline_key); } } else { // NEW CODE: Fall back to iterating over all pipelines debug!("ImagePrimitive::render - No Viewport found in storage, trying all pipelines"); // Find any pipeline that might be related to our texture and use it let mut rendered = false; if let Some((key, pipeline)) = ®istry.pipelines.iter().next() { debug!("ImagePrimitive::render - Trying pipeline with key: {}", key); pipeline.render(target, encoder, clip_bounds); rendered = true; debug!("ImagePrimitive::render - Successfully rendered with pipeline: {}", key); } if !rendered { debug!("ImagePrimitive::render - No pipelines found in registry"); } } } else { debug!("ImagePrimitive::render - No PipelineRegistry found in storage"); } } else { debug!("ImagePrimitive::render - Scene has NO texture!"); } } } // Registry to store pipelines #[derive(Debug)] pub struct PipelineRegistry { pipelines: HashMap, keys_order: VecDeque, // Tracks usage order max_pipelines: usize, // Maximum number of pipelines to keep } impl Default for PipelineRegistry { fn default() -> Self { Self { pipelines: HashMap::default(), keys_order: VecDeque::default(), max_pipelines: 30, // Default value, adjust based on your needs } } } impl PipelineRegistry { // Method to insert a pipeline with LRU tracking pub fn insert(&mut self, key: String, pipeline: TexturePipeline) { // If key already exists, update its position in the order list if self.pipelines.contains_key(&key) { // Remove the key from its current position if let Some(pos) = self.keys_order.iter().position(|k| k == &key) { self.keys_order.remove(pos); } } else if self.pipelines.len() >= self.max_pipelines && !self.keys_order.is_empty() { // We're at capacity and adding a new pipeline, remove the oldest one if let Some(oldest_key) = self.keys_order.pop_front() { self.pipelines.remove(&oldest_key); } } // Add the key to the end (most recently used) self.keys_order.push_back(key.clone()); // Insert or update the pipeline self.pipelines.insert(key, pipeline); } // Method to get a pipeline while updating LRU tracking pub fn _get(&mut self, key: &str) -> Option<&TexturePipeline> { if self.pipelines.contains_key(key) { // Update usage order: move this key to the end (most recently used) if let Some(pos) = self.keys_order.iter().position(|k| k == key) { self.keys_order.remove(pos); self.keys_order.push_back(key.to_string()); } return self.pipelines.get(key); } None } // Method to get a mutable pipeline while updating LRU tracking pub fn get_mut(&mut self, key: &str) -> Option<&mut TexturePipeline> { if self.pipelines.contains_key(key) { // Update usage order: move this key to the end (most recently used) if let Some(pos) = self.keys_order.iter().position(|k| k == key) { self.keys_order.remove(pos); self.keys_order.push_back(key.to_string()); } return self.pipelines.get_mut(key); } None } // Method to check if a key exists pub fn contains_key(&self, key: &str) -> bool { self.pipelines.contains_key(key) } // Non-mutable version of get that doesn't update LRU tracking pub fn get_ref(&self, key: &str) -> Option<&TexturePipeline> { self.pipelines.get(key) } } // Implement Widget for our ImageShader impl widget::Widget for ImageShader where Renderer: primitive::Renderer, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { let mut state = ImageShaderState::new(); if let Some(scale) = self.initial_scale { state.scale = scale; } if let Some(offset) = self.initial_offset { state.current_offset = offset; } tree::State::new(state) } fn diff(&self, tree: &mut Tree) { // Update the state with new zoom values if they were provided let state = tree.state.downcast_mut::(); if let Some(scale) = self.initial_scale { state.scale = scale; } if let Some(offset) = self.initial_offset { state.current_offset = offset; } } fn size(&self) -> Size { Size { width: self.width, height: self.height, } } fn layout( &self, _tree: &mut Tree, _renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { layout::atomic(limits, self.width, self.height) } fn on_event( &mut self, tree: &mut Tree, event: core::Event, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &Renderer, _clipboard: &mut dyn Clipboard, #[allow(unused_variables)] shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) -> event::Status { let bounds = layout.bounds(); // Adjust the effective mouse bounds to account for the split divider's expanded hitbox let effective_bounds = if self.is_horizontal_split { // For horizontal split, shrink top and bottom by the divider hitbox expansion amount Rectangle { x: bounds.x, y: bounds.y + DIVIDER_HITBOX_EXPANSION, width: bounds.width, height: bounds.height - (2.0 * DIVIDER_HITBOX_EXPANSION), } } else { // For vertical split, shrink left and right by the divider hitbox expansion amount Rectangle { x: bounds.x + DIVIDER_HITBOX_EXPANSION, y: bounds.y, width: bounds.width - (2.0 * DIVIDER_HITBOX_EXPANSION), height: bounds.height, } }; // Detect image change and sync zoom state to Pane #[cfg(feature = "coco")] { let state = tree.state.downcast_mut::(); if state.last_image_index != self.image_index { // Image changed - sync current zoom state to Pane (don't reset) state.last_image_index = self.image_index; // Emit zoom sync message with CURRENT state to keep zoom persistent if let Some(ref callback) = self.on_zoom_change { let message = callback(self.pane_index, state.scale, state.current_offset); shell.publish(message); } } } match event { core::Event::Mouse(mouse::Event::WheelScrolled { delta }) => { if !self.mouse_wheel_zoom && !self.ctrl_pressed { // log::debug!("image shader mouse scroll ignored"); return event::Status::Ignored; } let Some(cursor_position) = cursor.position_over(effective_bounds) else { return event::Status::Ignored; }; match delta { mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => { let state = tree.state.downcast_mut::(); let previous_scale = state.scale; if y < 0.0 && previous_scale > self.min_scale || y > 0.0 && previous_scale < self.max_scale { state.scale = (if y > 0.0 { state.scale * (1.0 + self.scale_step) } else { state.scale / (1.0 + self.scale_step) }) .clamp(self.min_scale, self.max_scale); // Calculate the scaled size let scaled_size = self.calculate_scaled_size(bounds.size(), state.scale); let factor = state.scale / previous_scale - 1.0; let cursor_to_center = cursor_position - bounds.center(); let adjustment = cursor_to_center * factor + state.current_offset * factor; state.current_offset = Vector::new( if scaled_size.width > bounds.width { state.current_offset.x + adjustment.x } else { 0.0 }, if scaled_size.height > bounds.height { state.current_offset.y + adjustment.y } else { 0.0 }, ); if self.debug { debug!("ImageShader::on_event - New scale: {}", state.scale); debug!("ImageShader::on_event - New offset: {:?}", state.current_offset); } // Emit zoom change message if callback is set #[cfg(feature = "coco")] if let Some(ref callback) = self.on_zoom_change { let message = callback(self.pane_index, state.scale, state.current_offset); shell.publish(message); } } } } event::Status::Captured } core::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { let Some(cursor_position) = cursor.position_over(effective_bounds) else { return event::Status::Ignored; }; let state = tree.state.downcast_mut::(); // Check for double-click if let Some(last_click_time) = state.last_click_time { let elapsed = last_click_time.elapsed(); if elapsed < std::time::Duration::from_millis(self.double_click_threshold_ms as u64) { // Double-click detected - reset zoom and pan state.scale = 1.0; state.current_offset = Vector::default(); state.starting_offset = Vector::default(); state.last_click_time = None; // Reset the current_offset to zero state.current_offset = Vector::default(); if self.debug { debug!("ImageShader::on_event - Double-click detected, resetting zoom and pan"); } // Emit zoom reset message if callback is set #[cfg(feature = "coco")] if let Some(ref callback) = self.on_zoom_change { let message = callback(self.pane_index, 1.0, Vector::default()); shell.publish(message); } return event::Status::Captured; } } // Update last click time for potential double-click detection state.last_click_time = Some(std::time::Instant::now()); // Continue with original click handling state.cursor_grabbed_at = Some(cursor_position); state.starting_offset = state.current_offset; if self.debug { debug!("ImageShader::on_event - Mouse grabbed at: {:?}", cursor_position); } event::Status::Captured } core::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { let state = tree.state.downcast_mut::(); if state.cursor_grabbed_at.is_some() { state.cursor_grabbed_at = None; // Emit zoom change message if callback is set (pan operation complete) #[cfg(feature = "coco")] if let Some(ref callback) = self.on_zoom_change { let message = callback(self.pane_index, state.scale, state.current_offset); shell.publish(message); } event::Status::Captured } else { event::Status::Ignored } } core::Event::Mouse(mouse::Event::CursorMoved { position }) => { let state = tree.state.downcast_mut::(); if let Some(origin) = state.cursor_grabbed_at { let scaled_size = self.calculate_scaled_size(bounds.size(), state.scale); let hidden_width = (scaled_size.width - bounds.width / 2.0) .max(0.0) .round(); let hidden_height = (scaled_size.height - bounds.height / 2.0) .max(0.0) .round(); let delta = position - origin; let x = if bounds.width < scaled_size.width { (state.starting_offset.x - delta.x) .clamp(-hidden_width, hidden_width) } else { 0.0 }; let y = if bounds.height < scaled_size.height { (state.starting_offset.y - delta.y) .clamp(-hidden_height, hidden_height) } else { 0.0 }; state.current_offset = Vector::new(x, y); if self.debug { debug!("ImageShader::on_event - Panning, new offset: {:?}", state.current_offset); } // Emit zoom change message during pan for real-time annotation updates #[cfg(feature = "coco")] if let Some(ref callback) = self.on_zoom_change { debug!("ImageShader: Publishing ZoomChanged during pan: scale={:.2}, offset=({:.1}, {:.1})", state.scale, state.current_offset.x, state.current_offset.y); let message = callback(self.pane_index, state.scale, state.current_offset); shell.publish(message); } event::Status::Captured } else { event::Status::Ignored } } _ => event::Status::Ignored, } } fn mouse_interaction( &self, tree: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); let is_mouse_over = cursor.is_over(bounds); if state.is_cursor_grabbed() { mouse::Interaction::Grabbing } else if is_mouse_over { if !self.mouse_wheel_zoom && !self.ctrl_pressed { mouse::Interaction::None } else { mouse::Interaction::Grab } } else { mouse::Interaction::None } } fn draw( &self, tree: &widget::Tree, renderer: &mut Renderer, _theme: &Theme, _style: &renderer::Style, layout: layout::Layout<'_>, _cursor: mouse::Cursor, _viewport: &Rectangle, ) { if let Some(scene) = &self.scene { let bounds = layout.bounds(); let state = tree.state.downcast_ref::(); // Calculate scaled content bounds with proper aspect ratio let scaled_size = self.calculate_scaled_size(bounds.size(), state.scale); // Apply offset let offset = state.offset(bounds, scaled_size); // Apply content fit with scaling let content_bounds = self.calculate_content_bounds(bounds, scaled_size, offset); if self.debug { debug!("ImageShader::draw - Scene available"); debug!("ImageShader::draw - Layout bounds: {:?}", bounds); debug!("ImageShader::draw - Content bounds: {:?}", content_bounds); } if scene.get_texture().is_some() { debug!("ImageShader::draw - Creating primitive with use_nearest_filter = {}", self.use_nearest_filter); let primitive = ImagePrimitive { scene: scene.clone(), bounds, content_bounds, scale: state.scale, offset, debug: self.debug, use_nearest_filter: self.use_nearest_filter, }; renderer.draw_primitive(bounds, primitive); } else { debug!("ImageShader::draw - Scene has NO texture! Skipping primitive creation"); } } else { debug!("ImageShader::draw - No scene available, nothing to draw"); } } } impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: 'a, Renderer: primitive::Renderer + 'a, { fn from(shader: ImageShader) -> Self { Element::new(shader) } } impl ImageShader { // Helper method to calculate scaled size based on content fit fn calculate_scaled_size(&self, bounds_size: Size, scale: f32) -> Size { if let Some(ref scene) = self.scene { if let Some(texture) = scene.get_texture() { let texture_size = Size::new(texture.width() as f32, texture.height() as f32); // Calculate base size according to content fit let base_size = match self.content_fit { ContentFit::Fill => bounds_size, ContentFit::Contain => { let width_ratio = bounds_size.width / texture_size.width; let height_ratio = bounds_size.height / texture_size.height; let ratio = width_ratio.min(height_ratio); Size::new(texture_size.width * ratio, texture_size.height * ratio) }, ContentFit::Cover => { let width_ratio = bounds_size.width / texture_size.width; let height_ratio = bounds_size.height / texture_size.height; let ratio = width_ratio.max(height_ratio); Size::new(texture_size.width * ratio, texture_size.height * ratio) }, ContentFit::ScaleDown => { let width_ratio = bounds_size.width / texture_size.width; let height_ratio = bounds_size.height / texture_size.height; let ratio = width_ratio.min(height_ratio).min(1.0); Size::new(texture_size.width * ratio, texture_size.height * ratio) }, ContentFit::None => texture_size, }; // Apply zoom scale return Size::new(base_size.width * scale, base_size.height * scale); } } // Fallback to original bounds if no texture bounds_size } // Helper method to calculate content bounds considering zoom and pan fn calculate_content_bounds(&self, bounds: Rectangle, scaled_size: Size, offset: Vector) -> Rectangle { // Calculate image position to center it let diff_w = bounds.width - scaled_size.width; let diff_h = bounds.height - scaled_size.height; let x = bounds.x + diff_w / 2.0 - offset.x; let y = bounds.y + diff_h / 2.0 - offset.y; // Apply 1px padding on all sides to avoid border overlap let padding = 1.0; Rectangle { x: x + padding, y: y + padding, width: scaled_size.width - 2.0 * padding, height: scaled_size.height - 2.0 * padding, } } pub fn with_interaction_state(mut self, mouse_wheel_zoom: bool, ctrl_pressed: bool) -> Self { self.mouse_wheel_zoom = mouse_wheel_zoom; self.ctrl_pressed = ctrl_pressed; self } /// Set the pane index for COCO zoom message emission #[cfg(feature = "coco")] pub fn pane_index(mut self, pane_index: usize) -> Self { self.pane_index = pane_index; self } /// Set callback for zoom/pan changes #[cfg(feature = "coco")] pub fn on_zoom_change(mut self, callback: F) -> Self where F: 'static + Fn(usize, f32, Vector) -> Message, { self.on_zoom_change = Some(Box::new(callback)); self } #[cfg(feature = "coco")] pub fn image_index(mut self, image_index: usize) -> Self { self.image_index = image_index; self } /// Set the filter mode for image rendering pub fn use_nearest_filter(mut self, use_nearest: bool) -> Self { self.use_nearest_filter = use_nearest; self } } ================================================ FILE: src/widgets/shader/mod.rs ================================================ pub mod scene; pub mod texture_pipeline; pub mod texture_scene; pub mod cpu_scene; pub mod image_shader; ================================================ FILE: src/widgets/shader/scene.rs ================================================ use std::sync::Arc; use std::sync::Mutex; use once_cell::sync::Lazy; use iced_widget::shader::{self, Viewport}; use iced_winit::core::{Rectangle, mouse}; use iced_wgpu::wgpu; use crate::cache::img_cache::CachedData; use crate::utils::timing::TimingStats; use crate::widgets::shader::texture_scene::TextureScene; use crate::widgets::shader::texture_scene::TexturePrimitive; use crate::widgets::shader::texture_pipeline::TexturePipeline; use crate::widgets::shader::cpu_scene::{CpuScene, CpuPrimitive}; static _SHADER_UPDATE_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("Shader Update")) }); #[derive(Debug, Clone)] pub enum Scene { TextureScene(TextureScene), CpuScene(CpuScene), } #[derive(Debug)] pub enum ScenePrimitive { Texture(TexturePrimitive), Cpu(CpuPrimitive), } impl Scene { pub fn new(initial_image: Option<&CachedData>) -> Self { match initial_image { Some(CachedData::Gpu(texture)) => { Scene::TextureScene(TextureScene::new(Some(&CachedData::Gpu(Arc::clone(texture))))) } Some(CachedData::BC1(texture)) => { Scene::TextureScene(TextureScene::new(Some(&CachedData::BC1(Arc::clone(texture))))) }, Some(CachedData::Cpu(image_bytes)) => { Scene::CpuScene(CpuScene::new(image_bytes.clone(), true)) }, _ => { Scene::TextureScene(TextureScene::new(None)) } } } pub fn get_texture(&self) -> Option<&Arc> { match self { Scene::TextureScene(scene) => scene.texture.as_ref(), Scene::CpuScene(scene) => scene.texture.as_ref(), } } pub fn update_texture(&mut self, texture: Arc) { match self { Scene::TextureScene(scene) => scene.update_texture(texture), Scene::CpuScene(_) => { // Not applicable for CPU scene } } } pub fn update_cpu_image(&mut self, image_bytes: Vec) { if let Scene::CpuScene(scene) = self { scene.update_image(image_bytes); } } pub fn has_valid_dimensions(&self) -> bool { match self { Scene::TextureScene(scene) => scene.texture_size.0 > 0 && scene.texture_size.1 > 0, Scene::CpuScene(scene) => scene.texture_size.0 > 0 && scene.texture_size.1 > 0, } } pub fn ensure_texture(&mut self, device: &Arc, queue: &Arc, pane_id: usize) { match self { Scene::CpuScene(cpu_scene) => { cpu_scene.ensure_texture(device, queue, &format!("pane_{}", pane_id)); } _ => { // Other scene types already have textures managed } } } } impl shader::Program for Scene { type State = (); type Primitive = ScenePrimitive; fn draw( &self, _state: &Self::State, cursor: mouse::Cursor, bounds: Rectangle, ) -> Self::Primitive { match self { Scene::TextureScene(scene) => { let texture_primitive = >::draw(scene, &(), cursor, bounds); ScenePrimitive::Texture(texture_primitive) } Scene::CpuScene(scene) => { let cpu_primitive = >::draw(scene, &(), cursor, bounds); ScenePrimitive::Cpu(cpu_primitive) } } } } impl shader::Primitive for ScenePrimitive { fn prepare( &self, device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat, storage: &mut shader::Storage, bounds: &Rectangle, viewport: &Viewport, ) { match self { ScenePrimitive::Texture(primitive) => { primitive.prepare(device, queue, format, storage, bounds, viewport) } ScenePrimitive::Cpu(primitive) => { primitive.prepare(device, queue, format, storage, bounds, viewport) } } } fn render( &self, encoder: &mut wgpu::CommandEncoder, storage: &shader::Storage, target: &wgpu::TextureView, clip_bounds: &Rectangle, ) { match self { ScenePrimitive::Texture(primitive) => { primitive.render(encoder, storage, target, clip_bounds) } ScenePrimitive::Cpu(primitive) => { primitive.render(encoder, storage, target, clip_bounds) } } } } #[allow(dead_code)] #[derive(Debug)] pub struct Primitive { texture: Arc, texture_size: (u32, u32), bounds: Rectangle, } impl Primitive { #[allow(dead_code)] pub fn new( texture: Arc, texture_size: (u32, u32), bounds: Rectangle, ) -> Self { Self { texture, texture_size, bounds, } } } impl shader::Primitive for Primitive { fn prepare( &self, device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat, storage: &mut shader::Storage, bounds: &Rectangle, viewport: &Viewport, ) { let scale_factor = viewport.scale_factor() as f32; let viewport_size = viewport.physical_size(); let shader_size = ( (bounds.width * scale_factor) as u32, (bounds.height * scale_factor) as u32, ); let bounds_relative = ( (bounds.x * scale_factor) / viewport_size.width as f32, (bounds.y * scale_factor) / viewport_size.height as f32, (bounds.width * scale_factor) / viewport_size.width as f32, (bounds.height * scale_factor) / viewport_size.height as f32, ); if !storage.has::() { storage.store(TexturePipeline::new( device, queue, format, self.texture.clone(), shader_size, self.texture_size, bounds_relative, false, // Default to Linear filter for legacy scene renderer )); } else { let pipeline = storage.get_mut::().unwrap(); pipeline.update_texture(device, queue, self.texture.clone(), false); } } fn render( &self, encoder: &mut wgpu::CommandEncoder, storage: &shader::Storage, target: &wgpu::TextureView, clip_bounds: &Rectangle, ) { let pipeline = storage.get::().unwrap(); pipeline.render(target, encoder, clip_bounds); } } ================================================ FILE: src/widgets/shader/texture.wgsl ================================================ @group(0) @binding(0) var my_texture: texture_2d; @group(0) @binding(1) var my_sampler: sampler; @group(0) @binding(2) var texture_rect: vec4; // {offset_x, offset_y, scale_x, scale_y} @group(0) @binding(3) var screen_rect: vec4; // {scaled_width, scaled_height, offset_x, offset_y} struct VertexOutput { @builtin(position) position: vec4, @location(0) tex_coords: vec2, }; @vertex fn vs_main( @location(0) position: vec2, @location(1) tex_coords: vec2, ) -> VertexOutput { var out: VertexOutput; // Simple pass-through of the position out.position = vec4(position, 0.0, 1.0); out.tex_coords = tex_coords; return out; } @fragment fn fs_main(@location(0) tex_coords: vec2) -> @location(0) vec4 { let color = textureSample(my_texture, my_sampler, tex_coords); return color; } ================================================ FILE: src/widgets/shader/texture_pipeline.rs ================================================ use std::sync::Arc; use std::sync::Mutex; use once_cell::sync::Lazy; use iced_core::Rectangle; use iced_wgpu::wgpu::{self, util::DeviceExt}; use crate::utils::timing::TimingStats; static _TEXTURE_UPDATE_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("Texture Update")) }); static _SHADER_RENDER_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("Shader Render")) }); #[derive(Debug)] pub struct TexturePipeline { pub pipeline: wgpu::RenderPipeline, pub vertex_buffer: wgpu::Buffer, pub bind_group: wgpu::BindGroup, pub index_buffer: wgpu::Buffer, pub num_indices: u32, pub texture: Arc, } impl TexturePipeline { pub fn new( device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat, texture: Arc, _render_size: (u32, u32), _image_size: (u32, u32), bounds_relative: (f32, f32, f32, f32), use_nearest_filter: bool, ) -> Self { let debug = false; let (x, y, width, height) = bounds_relative; if debug { println!("PIPELINE_INIT: Bounds relative: x={}, y={}, w={}, h={}", x, y, width, height); } // Convert to NDC coordinates (-1 to 1) let left = 2.0 * x - 1.0; let right = 2.0 * (x + width) - 1.0; let top = 1.0 - 2.0 * y; let bottom = 1.0 - 2.0 * (y + height); if debug { println!("PIPELINE_INIT: NDC coords: left={}, right={}, top={}, bottom={}", left, right, top, bottom); } // Create vertices - each vertex has position and texture coordinates // Format: [position.x, position.y, texcoord.x, texcoord.y] let vertices: [f32; 16] = [ left, bottom, 0.0, 1.0, // Bottom-left right, bottom, 1.0, 1.0, // Bottom-right right, top, 1.0, 0.0, // Top-right left, top, 0.0, 0.0, // Top-left ]; let indices: &[u16] = &[0, 1, 2, 2, 3, 0]; let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Quad Vertex Buffer"), contents: bytemuck::cast_slice(&vertices), usage: wgpu::BufferUsages::VERTEX, }); let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Index Buffer"), contents: bytemuck::cast_slice(indices), usage: wgpu::BufferUsages::INDEX, }); let filter_mode = if use_nearest_filter { wgpu::FilterMode::Nearest } else { wgpu::FilterMode::Linear }; let sampler = device.create_sampler(&wgpu::SamplerDescriptor { address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, mag_filter: filter_mode, min_filter: filter_mode, mipmap_filter: wgpu::FilterMode::Nearest, ..Default::default() }); let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); // Simplified binding layout - we don't need complex uniform buffers let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Bind Group Layout"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, ], }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { layout: &bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&texture_view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler), }, ], label: Some("Bind Group"), }); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Shader Module"), source: wgpu::ShaderSource::Wgsl(include_str!("./texture.wgsl").into()), }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Pipeline Layout"), bind_group_layouts: &[&bind_group_layout], push_constant_ranges: &[], }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Render Pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: "vs_main", buffers: &[wgpu::VertexBufferLayout { array_stride: 4 * std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &[ wgpu::VertexAttribute { offset: 0, shader_location: 0, format: wgpu::VertexFormat::Float32x2, }, wgpu::VertexAttribute { offset: 2 * std::mem::size_of::() as u64, shader_location: 1, format: wgpu::VertexFormat::Float32x2, }, ], }], }, primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { format, blend: Some(wgpu::BlendState::REPLACE), write_mask: wgpu::ColorWrites::ALL, })], }), multiview: None, }); Self { pipeline, vertex_buffer, bind_group, index_buffer, num_indices: indices.len() as u32, texture, } } pub fn update_texture( &mut self, device: &wgpu::Device, _queue: &wgpu::Queue, new_texture: Arc, use_nearest_filter: bool, ) { if Arc::ptr_eq(&self.texture, &new_texture) { return; // No update needed } self.texture = new_texture; let filter_mode = if use_nearest_filter { wgpu::FilterMode::Nearest } else { wgpu::FilterMode::Linear }; let sampler = device.create_sampler(&wgpu::SamplerDescriptor { address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, mag_filter: filter_mode, min_filter: filter_mode, mipmap_filter: wgpu::FilterMode::Nearest, ..Default::default() }); let texture_view = self.texture.create_view(&wgpu::TextureViewDescriptor::default()); self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { layout: &self.pipeline.get_bind_group_layout(0), entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&texture_view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler), }, ], label: Some("Updated Bind Group"), }); } pub fn render( &self, target: &wgpu::TextureView, encoder: &mut wgpu::CommandEncoder, clip_bounds: &Rectangle, ) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Texture Pipeline Render Pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, occlusion_query_set: None, timestamp_writes: None, }); pass.set_scissor_rect( clip_bounds.x, clip_bounds.y, clip_bounds.width, clip_bounds.height, ); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.bind_group, &[]); pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16); pass.draw_indexed(0..self.num_indices, 0, 0..1); } pub fn update_vertices( &self, _device: &wgpu::Device, _bounds_relative: (f32, f32, f32, f32), ) { // No-op: In our new design, we don't update vertices after creation // This is intentional to prevent jiggling } pub fn update_screen_uniforms( &self, _queue: &wgpu::Queue, _image_dimensions: (u32, u32), _shader_size: (u32, u32), _bounds_relative: (f32, f32, f32, f32), ) { // No-op: In our new design, we don't use screen uniforms anymore // This is intentional to simplify the pipeline } } ================================================ FILE: src/widgets/shader/texture_scene.rs ================================================ use std::sync::Arc; use std::sync::Mutex; use once_cell::sync::Lazy; use iced_core::{Length, Size, Point, ContentFit}; use iced_widget::shader::{self, Viewport}; use iced_winit::core::{Rectangle, mouse}; use iced_wgpu::wgpu; use crate::widgets::shader::texture_pipeline::TexturePipeline; use crate::cache::img_cache::CachedData; use crate::utils::timing::TimingStats; static _SHADER_UPDATE_STATS: Lazy> = Lazy::new(|| { Mutex::new(TimingStats::new("Shader Update")) }); #[derive(Debug, Clone)] pub struct TextureScene { pub texture: Option>, pub texture_size: (u32, u32), pub width: Length, pub height: Length, pub content_fit: ContentFit, // Use Iced's ContentFit enum } impl TextureScene { pub fn new(initial_image: Option<&CachedData>) -> Self { let (texture, texture_size) = match initial_image { Some(CachedData::Gpu(tex)) => ( Some(Arc::clone(tex)), (tex.width(), tex.height()) ), Some(CachedData::BC1(tex)) => ( Some(Arc::clone(tex)), (tex.width(), tex.height()) ), _ => (None, (0, 0)), }; TextureScene { texture, texture_size, width: Length::Fill, height: Length::Fill, content_fit: ContentFit::Contain, } } // Add builder methods like Iced's Image widget pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } pub fn content_fit(mut self, content_fit: ContentFit) -> Self { self.content_fit = content_fit; self } pub fn update_texture(&mut self, new_texture: Arc) { // Get width and height before moving the Arc let width = new_texture.width(); let height = new_texture.height(); self.texture = Some(new_texture); self.texture_size = (width, height); } } // Simplified primitive that just stores the layout rectangle and texture #[derive(Debug)] pub struct TexturePrimitive { pub texture: Arc, pub texture_size: (u32, u32), pub bounds: Rectangle, // Full widget bounds pub content_bounds: Rectangle, // Bounds that maintain aspect ratio } impl TexturePrimitive { pub fn new( texture: Arc, texture_size: (u32, u32), bounds: Rectangle, content_bounds: Rectangle, ) -> Self { Self { texture, texture_size, bounds, content_bounds, } } pub fn placeholder(_bounds: Rectangle) -> Self { // Create a 1x1 white texture as placeholder // Simplified implementation - you'd create a real placeholder texture unimplemented!("Need to create a placeholder texture") } } // Struct to hold multiple pipeline instances #[derive(Debug, Default)] pub struct PipelineRegistry { pipelines: std::collections::HashMap, } impl shader::Primitive for TexturePrimitive { fn prepare( &self, device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat, storage: &mut shader::Storage, _bounds: &Rectangle, viewport: &Viewport, ) { let debug = true; // CRITICAL: Enable debugging let scale_factor = viewport.scale_factor() as f32; let viewport_size = viewport.physical_size(); // CRUCIAL: The content_bounds preserve aspect ratio, we need to use these precisely let content_bounds = self.content_bounds; if debug { println!("###############PREPARE: Original bounds: {:?}", self.bounds); println!("###############PREPARE: Content bounds (aspect-preserved): {:?}", content_bounds); println!("###############PREPARE: Viewport size: {:?}", viewport_size); println!("###############PREPARE: Scale factor: {}", scale_factor); } // CRITICAL FIX: Calculate normalized device coordinates properly // These are percentages of the viewport, not percentages of the bounds let x_rel = content_bounds.x * scale_factor / viewport_size.width as f32; let y_rel = content_bounds.y * scale_factor / viewport_size.height as f32; let width_rel = content_bounds.width * scale_factor / viewport_size.width as f32; let height_rel = content_bounds.height * scale_factor / viewport_size.height as f32; let bounds_relative = (x_rel, y_rel, width_rel, height_rel); if debug { println!("PREPARE: Relative bounds: {:?}", bounds_relative); } // Create a pipeline with exactly these bounds let pipeline_key = format!("pipeline_{:.2}_{:.2}_{:.2}_{:.2}", bounds_relative.0, bounds_relative.1, bounds_relative.2, bounds_relative.3); // Registry setup if !storage.has::() { storage.store(PipelineRegistry::default()); } let registry = storage.get_mut::().unwrap(); // Create or update pipeline if !registry.pipelines.contains_key(&pipeline_key) { if debug { println!("Creating new TexturePipeline with bounds_relative: {:?}", bounds_relative); } let pipeline = TexturePipeline::new( device, queue, format, self.texture.clone(), (viewport_size.width, viewport_size.height), self.texture_size, bounds_relative, false, // Default to Linear filter for texture scene renderer ); registry.pipelines.insert(pipeline_key.clone(), pipeline); } else { // Only update the texture if needed let pipeline = registry.pipelines.get_mut(&pipeline_key).unwrap(); pipeline.update_texture(device, queue, self.texture.clone(), false); } } fn render( &self, encoder: &mut wgpu::CommandEncoder, storage: &shader::Storage, target: &wgpu::TextureView, clip_bounds: &Rectangle, ) { let content_bounds = self.content_bounds; // Calculate the same key used in prepare // This needs to match exactly what we used in prepare let x_rel = content_bounds.x / 1.0; // We don't have viewport size here let y_rel = content_bounds.y / 1.0; let width_rel = content_bounds.width / 1.0; let height_rel = content_bounds.height / 1.0; // Create a pipeline with exactly these bounds - need to match prepare exactly let pipeline_key = format!("pipeline_{:.2}_{:.2}_{:.2}_{:.2}", x_rel, y_rel, width_rel, height_rel); // Simply retrieve the pipeline and call its render method let registry = storage.get::().unwrap(); if let Some(pipeline) = registry.pipelines.get(&pipeline_key) { pipeline.render(target, encoder, clip_bounds); } } } impl shader::Program for TextureScene { type State = (); type Primitive = TexturePrimitive; fn draw( &self, _state: &Self::State, _cursor: mouse::Cursor, bounds: Rectangle, ) -> Self::Primitive { if let Some(texture) = &self.texture { // Calculate the content bounds based on content_fit let image_size = Size::new(self.texture_size.0 as f32, self.texture_size.1 as f32); let container_size = bounds.size(); // Apply content_fit to maintain aspect ratio let fitted_size = self.content_fit.fit(image_size, container_size); // Calculate position (centered in the bounds) let x = bounds.x + (bounds.width - fitted_size.width) / 2.0; let y = bounds.y + (bounds.height - fitted_size.height) / 2.0; // These are the actual bounds where the image should be drawn let content_bounds = Rectangle::new(Point::new(x, y), fitted_size); TexturePrimitive::new( Arc::clone(texture), self.texture_size, bounds, // Original layout bounds content_bounds, // Calculated content bounds ) } else { // Return a placeholder primitive if no texture TexturePrimitive::placeholder(bounds) } } } ================================================ FILE: src/widgets/split.rs ================================================ // Further modified from https://gist.github.com/airstrike/1169980e58ccb20a88e21af23dcf2650 // --- // Modified from iced_aw to work with iced master branch (~0.13). This // is provided AS IS—not really tested other than the fact that it compiles // https://github.com/iced-rs/iced_aw/blob/main/src/widgets/split.rs // https://github.com/iced-rs/iced_aw/blob/main/src/style/split.rs // MIT License // Copyright (c) 2020 Kaiden42 // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. //! Use a split to split the available space in two parts to display two different elements. //! //! *This API requires the following crate features to be activated: split* #[cfg(target_os = "linux")] mod other_os { pub use iced_custom as iced; } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; } #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; use iced::{ advanced::{ layout::{Limits, Node}, overlay, renderer, widget::{tree, Operation, Tree}, Clipboard, Layout, Shell, Widget, }, theme::palette, event, mouse::{self, Cursor}, touch, widget::Row, Background, Border, Color, Element, Event, Length, Padding, Point, Rectangle, Shadow, Size, Theme, Vector }; use iced::border::Radius; use std::time::{Duration, Instant}; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; use crate::CONFIG; /// Amount to expand the divider hitbox by on each side in pixels pub const DIVIDER_HITBOX_EXPANSION: f32 = 10.0; /// A split can divide the available space by half to display two different elements. /// It can split horizontally or vertically. /// /// # Example /// ```ignore /// # use iced_aw::split::{State, Axis, Split}; /// # use iced::widget::Text; /// # /// #[derive(Debug, Clone)] /// enum Message { /// Resized(u16), /// } /// /// let first = Text::new("First"); /// let second = Text::new("Second"); /// /// let split = Split::new(first, second, Some(300), Axis::Vertical, Message::Resized); /// ``` #[allow(missing_debug_implementations)] pub struct Split<'a, Message, Theme, Renderer> where Renderer: renderer::Renderer, Theme: Catalog, { /// The first element of the [`Split`]. first: Element<'a, Message, Theme, Renderer>, /// The second element of the [`Split`]. second: Element<'a, Message, Theme, Renderer>, is_selected: Vec, /// The position of the divider. divider_position: Option, //divider_init_position: Option, /// The axis to split at. axis: Axis, /// The padding around the elements of the [`Split`]. padding: f32, /// The spacing between the elements of the [`Split`]. /// This is also the width of the divider. spacing: f32, /// The width of the [`Split`]. width: Length, /// The height of the [`Split`]. height: Length, /// The minimum size of the first element of the [`Split`]. min_size_first: u16, /// The minimum size of the second element of the [`Split`]. min_size_second: u16, /// The message that is send when the divider of the [`Split`] is moved. on_resize: Box Message>, on_double_click: Box Message>, on_drop: Box Message>, on_select: Box Message>, class: Theme::Class<'a>, // Whether to enable pane selection enable_pane_selection: bool, // Field for the menu bar height menu_bar_height: f32, // Double-click threshold in milliseconds double_click_threshold_ms: u16, // Field for the debug flag debug: bool, } impl<'a, Message, Theme, Renderer> Split<'a, Message, Theme, Renderer> where Message: 'a, Renderer: 'a + renderer::Renderer, Theme: Catalog, { /// Creates a new [`Split`]. /// /// It expects: /// - The first [`Element`] to display /// - The second [`Element`] to display /// - The position of the divider. If none, the space will be split in half. /// - The [`Axis`] to split at. /// - The message that is send on moving the divider #[allow(clippy::too_many_arguments)] pub fn new( enable_pane_selection: bool, first: A, second: B, is_selected: Vec, divider_position: Option, axis: Axis, on_resize: F, on_double_click: G, on_drop: H, on_select: I, // Add menu_bar_height parameter, with a default of 0 menu_bar_height: f32, ) -> Self where A: Into>, B: Into>, F: 'static + Fn(u16) -> Message, G: 'static + Fn(u16) -> Message, H: 'static + Fn(isize, String) -> Message, I: 'static + Fn(usize, bool) -> Message, { Self { first: first.into(), // first: Container::new(first.into()) // .width(Length::Fill) // .height(Length::Fill) // .into(), second: second.into(), // second: Container::new(second.into()) // .width(Length::Fill) // .height(Length::Fill) // .into(), is_selected, divider_position, //divider_init_position: divider_position, axis, padding: 0.0, spacing: 5.0, // was 5.0 width: Length::Fill, height: Length::Fill, min_size_first: 5, min_size_second: 5, on_resize: Box::new(on_resize), on_double_click: Box::new(on_double_click), on_drop: Box::new(on_drop), on_select: Box::new(on_select), class: Theme::default(), enable_pane_selection, menu_bar_height, double_click_threshold_ms: CONFIG.double_click_threshold_ms, debug: false, } } /// Sets the double-click threshold in milliseconds. #[must_use] pub fn double_click_threshold_ms(mut self, threshold_ms: u16) -> Self { self.double_click_threshold_ms = threshold_ms; self } /// Sets the padding of the [`Split`] around the inner elements. #[must_use] pub fn padding(mut self, padding: f32) -> Self { self.padding = padding; self } /// Sets the spacing of the [`Split`] between the elements. /// This will also be the width of the divider. #[must_use] pub fn spacing(mut self, spacing: f32) -> Self { self.spacing = spacing; self } /// Sets the width of the [`Split`]. #[must_use] pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } /// Sets the height of the [`Split`]. #[must_use] pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } /// Sets the minimum size of the first element of the [`Split`]. #[must_use] pub fn min_size_first(mut self, size: u16) -> Self { self.min_size_first = size; self } /// Sets the minimum size of the second element of the [`Split`]. #[must_use] pub fn min_size_second(mut self, size: u16) -> Self { self.min_size_second = size; self } /// Sets the style of the [`Split`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self where Theme::Class<'a>: From>, { self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); self } /// Sets the style class of the [`Split`]. // #[cfg(feature = "advanced")] #[must_use] pub fn class(mut self, class: impl Into>) -> Self { self.class = class.into(); self } } impl<'a, Message, Theme, Renderer> Widget for Split<'a, Message, Theme, Renderer> where Message: 'a + Clone, Renderer: 'a + renderer::Renderer, Theme: Catalog, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(State::new()) } fn children(&self) -> Vec { vec![Tree::new(&self.first), Tree::new(&self.second)] } fn diff(&self, tree: &mut Tree) { tree.diff_children(&[&self.first, &self.second]); } fn size(&self) -> Size { Size::new(self.width, self.height) } fn layout( &self, tree: &mut Tree, renderer: &Renderer, limits: &Limits ) -> Node { let space = Row::::new() .width(Length::Fill) .height(Length::Fill) .layout(tree, renderer, limits); let config = SplitLayoutConfig { first: &self.first, second: &self.second, divider_position: self.divider_position, spacing: self.spacing, padding: self.padding, min_size_first: self.min_size_first, min_size_second: self.min_size_second, debug: self.debug, }; match self.axis { Axis::Horizontal => horizontal_split(tree, &config, renderer, limits, &space), Axis::Vertical => vertical_split(tree, &config, renderer, limits, &space), } } fn on_event( &mut self, state: &mut Tree, event: Event, layout: Layout<'_>, cursor: Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) -> event::Status { for child_layout in layout.children() { let _bounds = child_layout.bounds(); // debug!("cursor.is_over(bounds): {:?}", cursor.is_over(bounds)); } let split_state: &mut State = state.state.downcast_mut(); let mut children = layout.children(); let first_layout = children .next() .expect("Native: Layout should have a first layout"); let first_status = self.first.as_widget_mut().on_event( &mut state.children[0], event.clone(), first_layout, cursor, renderer, clipboard, shell, viewport, ); let divider_layout = children .next() .expect("Native: Layout should have a divider layout"); let second_layout = children .next() .expect("Graphics: Layout should have a second layout"); match event.clone() { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { // Detect double-click event on the divider if divider_layout .bounds() .expand(10.0) .contains(cursor.position().unwrap_or_default()) { split_state.dragging = true; // Save the current time if let Some(last_click_time) = split_state.last_click_time { let elapsed = last_click_time.elapsed(); if elapsed < Duration::from_millis(self.double_click_threshold_ms as u64) { // Double-click detected split_state.last_click_time = None; let double_click_position = match self.axis { Axis::Horizontal => cursor.position().map(|p| p.y), Axis::Vertical => cursor.position().map(|p| p.x), }; if let Some(position) = double_click_position { self.divider_position = None; split_state.dragging = false; shell.publish((self.on_double_click)(position as u16)); } } else { // Reset the timer for a new potential double-click split_state.last_click_time = Some(Instant::now()); } } else { split_state.last_click_time = Some(Instant::now()); } } // Detect pane selection if self.enable_pane_selection { let is_within_bounds_first = is_cursor_within_bounds(first_layout, cursor, 0, split_state); if is_within_bounds_first { split_state.panes_seleced[0] = !split_state.panes_seleced[0]; shell.publish((self.on_select)(0, split_state.panes_seleced[0])); } let is_within_bounds_second = is_cursor_within_bounds(second_layout, cursor, 1, split_state); if is_within_bounds_second { split_state.panes_seleced[1] = !split_state.panes_seleced[1]; shell.publish((self.on_select)(1, split_state.panes_seleced[1])); } } } #[cfg(any(target_os = "macos", target_os = "windows"))] Event::Window(iced::window::Event::FileHovered(position)) => { // Access the cursor position from the FileHovered event if self.debug { debug!("FILEHOVER POSITION: {:?}", position); } } #[cfg(target_os = "linux")] Event::Window(iced::window::Event::FileHovered(_path)) => { // Access the cursor position from the FileHovered event if self.debug { debug!("FileHovered Cursor position: {:?}", cursor.position().unwrap_or_default()); } } #[cfg(any(target_os = "macos", target_os = "windows"))] Event::Window(iced::window::Event::FileDropped(paths, position)) => { debug!("FILEDROP POSITION: {:?}", position); let mut children = layout.children(); let first_layout = children.next().expect("Missing first layout"); let _divider_layout = children.next().expect("Missing divider layout"); let second_layout = children.next().expect("Missing second layout"); // Convert position to Point for checking let custom_position = Point::new(position.x as f32, position.y as f32); // Check which pane contains the position if first_layout.bounds().contains(custom_position) { shell.publish((self.on_drop)(0, paths[0].to_string_lossy().to_string())); } else if second_layout.bounds().contains(custom_position) { shell.publish((self.on_drop)(1, paths[0].to_string_lossy().to_string())); } } #[cfg(target_os = "linux")] Event::Window(iced::window::Event::FileDropped(path, position)) => { let mut children = layout.children(); let first_layout = children.next().expect("Missing first layout"); let _divider_layout = children.next().expect("Missing divider layout"); let second_layout = children.next().expect("Missing second layout"); let drop_position = Point::new(position.x as f32, position.y as f32); debug!("FileDropped position: {:?}", drop_position); // Check first pane (index 0) if first_layout.bounds().contains(drop_position) { shell.publish((self.on_drop)(0, path[0].to_string_lossy().to_string())); } // Check second pane (index 1) else if second_layout.bounds().contains(drop_position) { shell.publish((self.on_drop)(1, path[0].to_string_lossy().to_string())); } } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) => { if split_state.dragging { split_state.dragging = false; } } Event::Mouse(mouse::Event::CursorMoved { position }) | Event::Touch(touch::Event::FingerMoved { position, .. }) => { if split_state.dragging { let position = match self.axis { // NOTE: Subtract menu_bar_height to account for the height of the menu bar // e.g., 16px base height coming from the font size + 4px padding on top/bottom sides Axis::Horizontal => position.y - self.menu_bar_height, Axis::Vertical => position.x, }; shell.publish((self.on_resize)(position as u16)); } } _ => {} } let second_status = self.second.as_widget_mut().on_event( &mut state.children[1], event, second_layout, cursor, renderer, clipboard, shell, viewport, ); first_status.merge(second_status) } fn mouse_interaction( &self, state: &Tree, layout: Layout<'_>, cursor: Cursor, viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { let mut children = layout.children(); let first_layout = children .next() .expect("Graphics: Layout should have a first layout"); let first_mouse_interaction = self.first.as_widget().mouse_interaction( &state.children[0], first_layout, cursor, viewport, renderer, ); let divider_layout = children .next() .expect("Graphics: Layout should have a divider layout"); // Use the constant instead of hardcoded value let divider_mouse_interaction = if divider_layout .bounds().expand(DIVIDER_HITBOX_EXPANSION) .contains(cursor.position().unwrap_or_default()) { match self.axis { Axis::Horizontal => mouse::Interaction::ResizingVertically, Axis::Vertical => mouse::Interaction::ResizingHorizontally, } } else { mouse::Interaction::default() }; let second_layout = children .next() .expect("Graphics: Layout should have a second layout"); let second_mouse_interaction = self.second.as_widget().mouse_interaction( &state.children[1], second_layout, cursor, viewport, renderer, ); first_mouse_interaction .max(second_mouse_interaction) .max(divider_mouse_interaction) } fn draw( &self, tree: &Tree, renderer: &mut Renderer, theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, cursor: Cursor, viewport: &Rectangle, ) { // TODO: clipping! let mut children = layout.children(); let bounds = layout.bounds(); let content_layout = layout.children().next().unwrap(); let is_mouse_over = cursor.is_over(bounds); let status = if is_mouse_over { let state = tree.state.downcast_ref::(); if state.dragging { Status::Dragging } else { Status::Hovered } } else { Status::Active }; let style = theme.style(&self.class, status); // Background renderer.fill_quad( renderer::Quad { bounds: content_layout.bounds(), border: style.border, shadow: Shadow::default(), }, style .background .unwrap_or_else(|| Color::TRANSPARENT.into()), ); let first_layout = children .next() .expect("Graphics: Layout should have a first layout"); let bounds_first = first_layout.bounds(); let is_mouse_over_first = cursor.is_over(bounds_first); let status_first = if is_mouse_over_first { let state = tree.state.downcast_ref::(); if state.dragging { Status::Dragging } else { Status::Hovered } } else { Status::Active }; let style_first = theme.style(&self.class, status_first); // First renderer.fill_quad( renderer::Quad { bounds: bounds_first, border: style_first.first_border, shadow: Shadow::default(), }, style_first .first_background .unwrap_or_else(|| Color::TRANSPARENT.into()), ); self.first.as_widget().draw( &tree.children[0], renderer, theme, &renderer::Style::default(), first_layout, cursor, viewport, ); let divider_layout = children .next() .expect("Graphics: Layout should have a divider layout"); // Second let second_layout = children .next() .expect("Graphics: Layout should have a second layout"); let bounds_second = second_layout.bounds(); let is_mouse_over_second = cursor.is_over(bounds_second); let status_second = if is_mouse_over_second { let state = tree.state.downcast_ref::(); if state.dragging { Status::Dragging } else { Status::Hovered } } else { Status::Active }; let style_second = theme.style(&self.class, status_second); renderer.fill_quad( renderer::Quad { bounds: bounds_second, border: style_second.second_border, shadow: Shadow::default(), }, style_second .second_background .unwrap_or_else(|| Color::TRANSPARENT.into()), ); self.second.as_widget().draw( &tree.children[1], renderer, theme, &renderer::Style::default(), second_layout, cursor, viewport, ); let bounds_divider = divider_layout.bounds(); let is_mouse_over_divider = cursor.is_over(bounds_divider.expand(DIVIDER_HITBOX_EXPANSION / 2.0)); let status_divider = if is_mouse_over_divider { let state = tree.state.downcast_ref::(); if state.dragging { Status::Dragging } else { Status::Hovered } } else { Status::Active }; let style_divider = theme.style(&self.class, status_divider); let bounds = divider_layout.bounds(); let is_horizontal = bounds.width >= bounds.height; // Create a modified Rectangle for a thin line, centered within the divider area let thin_rectangle = if is_horizontal { // For horizontal dividers Rectangle { x: bounds.x, y: bounds.y + (bounds.height - 1.0) / 2.0, // Center the 1px line width: bounds.width + 10.0, height: 1.0, } } else { // For vertical dividers Rectangle { x: bounds.x + (bounds.width - 1.0) / 2.0, // Center the 1px line y: bounds.y, width: 1.0, height: bounds.height + 10.0, // `+ 10.0` is needed to make the divider reach the top edge of footer } }; // Draw the divider (thin line) renderer.fill_quad( renderer::Quad { bounds: thin_rectangle, border: Border { color: style_divider.border.color, width: 0.0, radius: Radius::new(0.0), }, shadow: Default::default(), // No shadow }, Background::Color(Color::from_rgb(0.2, 0.2, 0.2)), ); let style = theme.style(&self.class, Status::Active); // Draw pane selection status; if selected, draw a border around the pane if self.enable_pane_selection { if self.is_selected[0] { renderer.fill_quad( renderer::Quad { bounds: first_layout.bounds(), border: Border { color: style.primary.base.color, width: 1.0, radius: Radius::new(0.0), }, shadow: Default::default(), // Use Default for no shadow }, Background::Color(Color::TRANSPARENT), ); } if self.is_selected[1] { renderer.fill_quad( renderer::Quad { bounds: second_layout.bounds(), border: Border { color: style.primary.base.color, width: 1.0, radius: Radius::new(0.0), }, shadow: Default::default(), // Use Default for no shadow }, Background::Color(Color::TRANSPARENT), ); } } } fn operate<'b>( &'b self, state: &'b mut Tree, layout: Layout<'_>, renderer: &Renderer, //operation: &mut dyn Operation, operation: &mut dyn Operation, ) { let mut children = layout.children(); let first_layout = children.next().expect("Missing Split First window"); let _divider_layout = children.next().expect("Missing Split Divider"); let second_layout = children.next().expect("Missing Split Second window"); let (first_state, second_state) = state.children.split_at_mut(1); self.first .as_widget() .operate(&mut first_state[0], first_layout, renderer, operation); self.second .as_widget() .operate(&mut second_state[0], second_layout, renderer, operation); } fn overlay<'b>( &'b mut self, state: &'b mut Tree, layout: Layout<'_>, renderer: &Renderer, translation: Vector, ) -> Option> { let mut children = layout.children(); let first_layout = children.next()?; let _divider_layout = children.next()?; let second_layout = children.next()?; let first = &mut self.first; let second = &mut self.second; // Not pretty but works to get two mutable references // https://stackoverflow.com/a/30075629 let (first_state, second_state) = state.children.split_at_mut(1); first .as_widget_mut() .overlay(&mut first_state[0], first_layout, renderer, translation) .or_else(|| { second.as_widget_mut().overlay( &mut second_state[0], second_layout, renderer, translation, ) }) } } // Helper function to process a layout and check for cursor position // This function assumes that the first child of the container is the Image widget // TODO: Fix hardcoding fn is_cursor_within_bounds( layout: Layout<'_>, cursor: Cursor, _pane_index: usize, _split_state: &mut State, ) -> bool { if let Some(container_layout) = layout.children().next() { if let Some(image_layout) = container_layout.children().next() { let image_bounds = image_layout.bounds(); if image_bounds.contains(cursor.position().unwrap_or_default()) { return true; } } } false } /// The state of a [`Split`]. #[derive(Clone, Debug, Default)] pub struct State { /// If the divider is dragged by the user. dragging: bool, last_click_time: Option, panes_seleced: [bool; 2], } impl State { /// Creates a new [`State`] for a [`Split`]. /// /// It expects: /// - The optional position of the divider. If none, the available space will be split in half. /// - The [`Axis`] to split at. #[must_use] pub const fn new() -> Self { Self { dragging: false, last_click_time: None, //panes_seleced: [false, false], panes_seleced: [true, true], } } } /// Do a horizontal split. pub fn horizontal_split<'a, Message, Theme, Renderer>( tree: &mut Tree, config: &SplitLayoutConfig<'a, Message, Theme, Renderer>, renderer: &Renderer, limits: &Limits, space: &Node, ) -> Node where Renderer: renderer::Renderer, Theme: Catalog, { let total_height = space.bounds().height; if total_height < config.spacing + f32::from(config.min_size_first + config.min_size_second) { return Node::with_children(space.bounds().size(), vec![ config.first.as_widget().layout( &mut tree.children[0], renderer, &limits.clone().shrink(Size::new(0.0, total_height)), ), Node::new(Size::new(space.bounds().width, config.spacing)), config.second.as_widget().layout( &mut tree.children[1], renderer, &limits.clone().shrink(Size::new(0.0, total_height)), ), ]); } // Calculate available content height (total minus spacing) let available_content_height = total_height - config.spacing; // Calculate equal height for both panes let equal_pane_height = available_content_height / 2.0; // Default divider position is set to create equal panes let divider_position = config.divider_position.unwrap_or(equal_pane_height as u16); // divider_position is always positive: measure from start (top) let effective_position = divider_position.max(((config.spacing / 2.0) as i16).try_into().unwrap()) as f32; // Clamp the effective position to respect minimum sizes let clamped_position = effective_position.clamp( config.min_size_first as f32, total_height - config.min_size_second as f32 - config.spacing, ); let padding = Padding::from(config.padding as u16); // Layout first element let first_limits = limits .clone() .shrink(Size::new(0.0, total_height - clamped_position)) .shrink(padding); let mut first = config.first.as_widget().layout( &mut tree.children[0], renderer, &first_limits ); first.move_to_mut(Point::new( space.bounds().x + config.padding, space.bounds().y + config.padding, )); // Keep the divider code mostly unchanged, but make the node as tall as split.spacing // The actual divider line will be drawn centered within this space let mut divider = Node::new(Size::new(space.bounds().width, config.spacing)); divider.move_to_mut(Point::new(space.bounds().x, clamped_position)); // Layout second element let second_limits = limits .clone() .shrink(Size::new(0.0, clamped_position + config.spacing)) .shrink(padding); let mut second = config.second.as_widget().layout( &mut tree.children[1], renderer, &second_limits ); second.move_to_mut(Point::new( space.bounds().x + config.padding, space.bounds().y + clamped_position + config.spacing + config.padding, )); // Debug logs to verify positions and heights if config.debug{ debug!("HORIZONTAL Split: equal_pane_height={}, first_y={}, divider_y={}, second_y={}, first_height={}, second_height={}", equal_pane_height, space.bounds().y + config.padding, clamped_position, space.bounds().y + clamped_position + config.spacing + config.padding, clamped_position - (space.bounds().y + config.padding), total_height - (clamped_position + config.spacing + config.padding*2.0)); } // Maintain the original 3-node structure expected by other methods Node::with_children(space.bounds().size(), vec![first, divider, second]) } /// Do a vertical split. pub fn vertical_split<'a, Message, Theme, Renderer>( tree: &mut Tree, config: &SplitLayoutConfig<'a, Message, Theme, Renderer>, renderer: &Renderer, limits: &Limits, space: &Node, ) -> Node where Renderer: renderer::Renderer, Theme: Catalog, { let bounds = space.bounds(); if config.debug{ debug!("VERTICAL Split calculation - bounds: {:?}", bounds); } if space.bounds().width < config.spacing + f32::from(config.min_size_first + config.min_size_second) { debug!("VERTICAL Split - insufficient width for proper split, using fallback layout"); return Node::with_children( space.bounds().size(), vec![ config.first.as_widget().layout( &mut tree.children[0], renderer, &limits.clone().shrink(Size::new(space.bounds().width, 0.0)), ), Node::new(Size::new(config.spacing, space.bounds().height)), config.second.as_widget().layout( &mut tree.children[1], renderer, &limits.clone().shrink(Size::new(space.bounds().width, 0.0)), ), ], ); } // Calculate the actual size available for content (excluding padding) let available_width = space.bounds().width - (2.0 * config.padding); // Define spacing around the divider (on each side) let gap = config.spacing; // Gap between content and divider let divider_width = 1.0; // Width of the actual divider line //let total_spacing = 2.0 * gap + divider_width; // Total space needed for divider + gaps // Calculate the divider position let divider_position = config .divider_position .unwrap_or_else(|| (available_width / 2.0) as u16); // Explicitly calculate the actual minimum width constraints // Left minimum constraint (distance from left edge to divider center) let min_left_divider_position = config.min_size_first as f32 + gap; // Right minimum constraint (distance from right edge to divider center) let min_right_divider_position = available_width - (config.min_size_second as f32) - gap; // Ensure divider position respects both minimum constraints let divider_position_constrained = divider_position as f32; let divider_position_constrained = divider_position_constrained.max(min_left_divider_position); let divider_position_constrained = divider_position_constrained.min(min_right_divider_position); if config.debug { debug!("VERTICAL Split - min constraints: left min={}, right min={}, constrained pos={}", min_left_divider_position, min_right_divider_position, divider_position_constrained); } // Calculate positions of elements with original offset let divider_center_x = space.bounds().x + config.padding + divider_position_constrained; // The first element should end before the left gap let first_end_x = divider_center_x - gap; // The divider should be in the center of the gap let divider_left_x = divider_center_x - divider_width/2.0; // The second element should start after the right gap let second_start_x = divider_center_x + gap; // Layout the first element with appropriate width let first_width = first_end_x - (space.bounds().x + config.padding); let first_limits = limits .width(first_width) .shrink(Padding::from(config.padding as u16)); let mut first = config.first.as_widget().layout( &mut tree.children[0], renderer, &first_limits ); first.move_to_mut(Point::new( space.bounds().x + config.padding, space.bounds().y + config.padding, )); // Create the divider node (thin line in center) let mut divider = Node::new(Size::new(divider_width, space.bounds().height)); divider.move_to_mut(Point::new(divider_left_x, space.bounds().y)); // Layout the second element let second_width = space.bounds().width - second_start_x - config.padding; let second_limits = limits .width(second_width) .shrink(Padding::from(config.padding as u16)); let mut second = config .second .as_widget() .layout(&mut tree.children[1], renderer, &second_limits); second.move_to_mut(Point::new( second_start_x, space.bounds().y + config.padding, )); let result = Node::with_children(space.bounds().size(), vec![first, divider, second]); if config.debug{ debug!("VERTICAL Pane sizes - min_size_first: {}, min_size_second: {}", config.min_size_first, config.min_size_second); debug!("VERTICAL Final widths - first: {}, divider: {}, second: {}", first_width, divider_width, second_width); // Debug output to verify the bounds are correct let children = result.children(); if children.len() >= 3 { debug!("VERTICAL First pane bounds: {:?}", children[0].bounds()); debug!("VERTICAL Divider bounds: {:?}", children[1].bounds()); debug!("VERTICAL Second pane bounds: {:?}", children[2].bounds()); } } result } impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: Clone + 'a, Renderer: renderer::Renderer + 'a, Theme: Catalog + 'a, { fn from(split_pane: Split<'a, Message, Theme, Renderer>) -> Self { Element::new(split_pane) } } /// The axis to split at. #[derive(Clone, Copy, Debug)] pub enum Axis { /// Split horizontally. Horizontal, /// Split vertically. Vertical, } impl Default for Axis { fn default() -> Self { Self::Vertical } } /// The possible statuses of a [`Split`]. #[derive(Debug, Clone, Copy)] pub enum Status { /// The [`Split`] can be dragged. Active, /// The [`Split`] can be dragged and it is being hovered. Hovered, /// The [`Split`] is being dragged. Dragging, /// The [`Split`] cannot be dragged. Disabled, } /// The style of a [`Split`]. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Style { /// The optional background of the [`Split`]. pub background: Option, /// The optional background of the first element of the [`Split`]. pub first_background: Option, /// The optional background of the second element of the [`Split`]. pub second_background: Option, /// The [`Border`] of the [`Split`]. pub border: Border, /// The [`Border`] of the [`Split`]. pub first_border: Border, /// The [`Border`] of the [`Split`]. pub second_border: Border, /// The background of the divider of the [`Split`]. pub divider_background: Background, /// The [`Border`] of the divider of the [`Split`]. pub divider_border: Border, /// The primary color of the [`Split`]. pub primary: palette::Primary, } impl Style { /// Updates the [`Style`] with the given [`Background`]. pub fn with_background(self, background: impl Into) -> Self { Self { background: Some(background.into()), ..self } } } impl Default for Style { fn default() -> Self { Self { background: None, first_background: None, second_background: None, border: Border::default(), first_border: Border::default(), second_border: Border::default(), divider_background: Background::Color(Color::TRANSPARENT), divider_border: Border::default(), primary: palette::Primary::generate( Color::TRANSPARENT, Color::TRANSPARENT, Color::TRANSPARENT, ), } } } /// The theme catalog of a [`Split`]. pub trait Catalog { /// The item class of the [`Split`]. type Class<'a>; /// The default class produced by the [`Split`]. fn default<'a>() -> Self::Class<'a>; /// The [`Style`] of a class with the given status. fn style(&self, class: &Self::Class<'_>, status: Status) -> Style; } /// A styling function for a [`Split`]. pub type StyleFn<'a, Theme> = Box Style + 'a>; impl Catalog for Theme { type Class<'a> = StyleFn<'a, Self>; fn default<'a>() -> Self::Class<'a> { Box::new(default) } fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { class(self, status) } } pub fn default(theme: &Theme, status: Status) -> Style { let palette = theme.extended_palette(); let base = base(*palette); match status { Status::Active => base, Status::Hovered => base, Status::Dragging => base, Status::Disabled => disabled(base), } } fn base(palette: palette::Extended) -> Style { Style { background: Some(Background::Color(palette.background.base.color)), border: Border::rounded(Border { color: Color::TRANSPARENT, width: 0.0, radius: Radius::new(0.0), }, 2.0), primary: palette.primary, ..Style::default() } } fn disabled(style: Style) -> Style { Style { background: style .background .map(|background| background.scale_alpha(0.5)), ..style } } pub struct SplitLayoutConfig<'a, Message, Theme, Renderer> { pub first: &'a Element<'a, Message, Theme, Renderer>, pub second: &'a Element<'a, Message, Theme, Renderer>, pub divider_position: Option, pub spacing: f32, pub padding: f32, pub min_size_first: u16, pub min_size_second: u16, pub debug: bool, } ================================================ FILE: src/widgets/synced_image_split.rs ================================================ // Further modified from https://gist.github.com/airstrike/1169980e58ccb20a88e21af23dcf2650 // --- // Modified from iced_aw to work with iced master branch (~0.13). This // is provided AS IS—not really tested other than the fact that it compiles // https://github.com/iced-rs/iced_aw/blob/main/src/widgets/split.rs // https://github.com/iced-rs/iced_aw/blob/main/src/style/split.rs // MIT License // Copyright (c) 2020 Kaiden42 // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. //! Use a split to split the available space in two parts to display two different elements. //! //! *This API requires the following crate features to be activated: split* #[cfg(target_os = "linux")] mod other_os { pub use iced_custom as iced; } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; } #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; use iced::{ advanced::{ layout::{Limits, Node}, overlay, renderer, widget::{tree, Operation, Tree}, Clipboard, Layout, Shell, Widget, }, event, mouse::{self, Cursor}, touch, widget::Row, Background, Border, Color, Element, Event, Length, Point, Rectangle, Shadow, Size, Vector }; use iced::border::Radius; use crate::widgets::split::{horizontal_split, vertical_split, SplitLayoutConfig}; use std::time::{Duration, Instant}; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; use iced_core::widget; use crate::widgets::split::Axis; use crate::widgets::split::{Catalog, Status, Style, StyleFn}; use crate::CONFIG; // Add module-level debug flag - set to false to disable all debug logs const DEBUG_LOGS_ENABLED: bool = false; // Define a macro for conditional debug printing macro_rules! debug_log { ($($arg:tt)*) => { if DEBUG_LOGS_ENABLED { debug!($($arg)*); } }; } /// A split can divide the available space by half to display two different elements. /// It can split horizontally or vertically. /// /// # Example /// ```ignore /// # use iced_aw::split::{State, Axis, Split}; /// # use iced::widget::Text; /// # /// #[derive(Debug, Clone)] /// enum Message { /// Resized(u16), /// } /// /// let first = Text::new("First"); /// let second = Text::new("Second"); /// /// let split = SyncedImageSplit::new(first, second, Some(300), Axis::Vertical, Message::Resized); /// ``` #[allow(missing_debug_implementations)] pub struct SyncedImageSplit<'a, Message, Theme, Renderer> where Renderer: renderer::Renderer, Theme: Catalog, { /// The first element of the [`Split`]. first: Element<'a, Message, Theme, Renderer>, /// The second element of the [`Split`]. second: Element<'a, Message, Theme, Renderer>, is_selected: Vec, /// The position of the divider. divider_position: Option, //divider_init_position: Option, /// The axis to split at. axis: Axis, /// The padding around the elements of the [`Split`]. padding: f32, /// The spacing between the elements of the [`Split`]. /// This is also the width of the divider. spacing: f32, /// The width of the [`Split`]. width: Length, /// The height of the [`Split`]. height: Length, /// The minimum size of the first element of the [`Split`]. min_size_first: u16, /// The minimum size of the second element of the [`Split`]. min_size_second: u16, /// The message that is send when the divider of the [`Split`] is moved. on_resize: Box Message>, on_double_click: Box Message>, on_drop: Box Message>, on_select: Box Message>, class: Theme::Class<'a>, // Whether to enable pane selection enable_pane_selection: bool, // Add a new field for the menu bar height menu_bar_height: f32, // Add a flag to control synced zooming synced_zoom: bool, // Add zoom control parameters min_scale: f32, max_scale: f32, scale_step: f32, // Double-click threshold in milliseconds double_click_threshold_ms: u16, } impl<'a, Message, Theme, Renderer> SyncedImageSplit<'a, Message, Theme, Renderer> where Message: 'a, Renderer: 'a + renderer::Renderer, Theme: Catalog, { /// Creates a new [`Split`]. /// /// It expects: /// - The first [`Element`] to display /// - The second [`Element`] to display /// - The position of the divider. If none, the space will be split in half. /// - The [`Axis`] to split at. /// - The message that is send on moving the divider #[allow(clippy::too_many_arguments)] pub fn new( enable_pane_selection: bool, first: A, second: B, is_selected: Vec, divider_position: Option, axis: Axis, on_resize: F, on_double_click: G, on_drop: H, on_select: I, // Add menu_bar_height parameter, with a default of 0 menu_bar_height: f32, synced_zoom: bool, // Add synced_zoom parameter ) -> Self where A: Into>, B: Into>, F: 'static + Fn(u16) -> Message, G: 'static + Fn(u16) -> Message, H: 'static + Fn(isize, String) -> Message, I: 'static + Fn(usize, bool) -> Message, { Self { first: first.into(), // first: Container::new(first.into()) // .width(Length::Fill) // .height(Length::Fill) // .into(), second: second.into(), // second: Container::new(second.into()) // .width(Length::Fill) // .height(Length::Fill) // .into(), is_selected, divider_position, //divider_init_position: divider_position, axis, padding: 0.0, spacing: 5.0, // was 5.0 width: Length::Fill, height: Length::Fill, min_size_first: 20, min_size_second: 20, on_resize: Box::new(on_resize), on_double_click: Box::new(on_double_click), on_drop: Box::new(on_drop), on_select: Box::new(on_select), class: Theme::default(), enable_pane_selection, menu_bar_height, // Initialize zoom settings synced_zoom, min_scale: 0.25, max_scale: 10.0, scale_step: 0.10, double_click_threshold_ms: CONFIG.double_click_threshold_ms, } } /// Sets the double-click threshold in milliseconds. #[must_use] pub fn double_click_threshold_ms(mut self, threshold_ms: u16) -> Self { self.double_click_threshold_ms = threshold_ms; self } /// Sets the padding of the [`Split`] around the inner elements. #[must_use] pub fn padding(mut self, padding: f32) -> Self { self.padding = padding; self } /// Sets the spacing of the [`Split`] between the elements. /// This will also be the width of the divider. #[must_use] pub fn spacing(mut self, spacing: f32) -> Self { self.spacing = spacing; self } /// Sets the width of the [`Split`]. #[must_use] pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } /// Sets the height of the [`Split`]. #[must_use] pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } /// Sets the minimum size of the first element of the [`Split`]. #[must_use] pub fn min_size_first(mut self, size: u16) -> Self { self.min_size_first = size; self } /// Sets the minimum size of the second element of the [`Split`]. #[must_use] pub fn min_size_second(mut self, size: u16) -> Self { self.min_size_second = size; self } /// Sets the style of the [`Split`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self where Theme::Class<'a>: From>, { self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); self } /// Sets the style class of the [`Split`]. // #[cfg(feature = "advanced")] #[must_use] pub fn class(mut self, class: impl Into>) -> Self { self.class = class.into(); self } // Add methods to customize zoom parameters #[must_use] pub fn min_scale(mut self, min_scale: f32) -> Self { self.min_scale = min_scale; self } #[must_use] pub fn max_scale(mut self, max_scale: f32) -> Self { self.max_scale = max_scale; self } #[must_use] pub fn scale_step(mut self, scale_step: f32) -> Self { self.scale_step = scale_step; self } #[must_use] pub fn synced_zoom(mut self, synced_zoom: bool) -> Self { self.synced_zoom = synced_zoom; self } } impl<'a, Message, Theme, Renderer> Widget for SyncedImageSplit<'a, Message, Theme, Renderer> where Message: 'a + Clone, Renderer: 'a + renderer::Renderer, Theme: Catalog, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(State::new()) } fn children(&self) -> Vec { vec![Tree::new(&self.first), Tree::new(&self.second)] } fn diff(&self, tree: &mut Tree) { tree.diff_children(&[&self.first, &self.second]); } fn size(&self) -> Size { Size::new(self.width, self.height) } fn layout( &self, tree: &mut Tree, renderer: &Renderer, limits: &Limits ) -> Node { let space = Row::::new() .width(Length::Fill) .height(Length::Fill) .layout(tree, renderer, limits); let config = SplitLayoutConfig { first: &self.first, second: &self.second, divider_position: self.divider_position, spacing: self.spacing, padding: self.padding, min_size_first: self.min_size_first, min_size_second: self.min_size_second, debug: false, }; match self.axis { Axis::Horizontal => horizontal_split(tree, &config, renderer, limits, &space), Axis::Vertical => vertical_split(tree, &config, renderer, limits, &space), } } fn on_event( &mut self, state: &mut Tree, event: Event, layout: Layout<'_>, cursor: Cursor, renderer: &Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) -> event::Status { // Get split state let split_state = state.state.downcast_mut::(); // Ensure synced_zoom state is updated split_state.synced_zoom = self.synced_zoom; let is_wheel_event = matches!(event, Event::Mouse(mouse::Event::WheelScrolled { .. })); if is_wheel_event { debug_log!("SyncedImageSplit: synced_zoom = {}", split_state.synced_zoom); } let mut children = layout.children(); let first_layout = children .next() .expect("Native: Layout should have a first layout"); let divider_layout = children .next() .expect("Native: Layout should have a divider layout"); let second_layout = children .next() .expect("Native: Layout should have a second layout"); // Special direct handling for wheel events when synced_zoom is enabled if split_state.synced_zoom && is_wheel_event { if let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event { debug_log!("Wheel event with delta: {:?}", delta); // Find which pane the cursor is over let cursor_pos = cursor.position().unwrap_or_default(); let first_bounds = first_layout.bounds(); let second_bounds = second_layout.bounds(); let (target_pane, target_layout, other_layout) = if first_bounds.contains(cursor_pos) { (0, first_layout, second_layout) } else if second_bounds.contains(cursor_pos) { (1, second_layout, first_layout) } else { debug_log!("Cursor outside both panes - ignoring"); // Outside either pane, process normally let first_status = self.first.as_widget_mut().on_event( &mut state.children[0], event.clone(), first_layout, cursor, renderer, clipboard, shell, viewport, ); let second_status = self.second.as_widget_mut().on_event( &mut state.children[1], event.clone(), second_layout, cursor, renderer, clipboard, shell, viewport, ); return first_status.merge(second_status); }; // Calculate relative cursor position within target pane (0.0-1.0) // This gives us the anchor point for zoom let target_bounds = target_layout.bounds(); let normalized_cursor = Point::new( (cursor_pos.x - target_bounds.x) / target_bounds.width, (cursor_pos.y - target_bounds.y) / target_bounds.height ); // Calculate corresponding point in other pane let other_bounds = other_layout.bounds(); let other_cursor_pos = Point::new( other_bounds.x + normalized_cursor.x * other_bounds.width, other_bounds.y + normalized_cursor.y * other_bounds.height ); debug_log!("Processing wheel event in pane {}", target_pane); // Handle target pane with actual cursor let target_status = if target_pane == 0 { self.first.as_widget_mut().on_event( &mut state.children[0], event.clone(), target_layout, cursor, renderer, clipboard, shell, viewport, ) } else { self.second.as_widget_mut().on_event( &mut state.children[1], event.clone(), target_layout, cursor, renderer, clipboard, shell, viewport, ) }; // Handle other pane with simulated cursor at corresponding position let other_status = if target_pane == 0 { self.second.as_widget_mut().on_event( &mut state.children[1], event.clone(), other_layout, Cursor::Available(other_cursor_pos), renderer, clipboard, shell, viewport, ) } else { self.first.as_widget_mut().on_event( &mut state.children[0], event.clone(), other_layout, Cursor::Available(other_cursor_pos), renderer, clipboard, shell, viewport, ) }; debug_log!("Processed wheel events in both panes"); // After processing wheel events in both panes, ensure full state synchronization if target_status == event::Status::Captured || other_status == event::Status::Captured { // First, query the state from the target pane let (first_state, second_state) = state.children.split_at_mut(1); let mut query_op = ZoomStateOperation::new_query(); if target_pane == 0 { ZoomStateOperation::operate( &mut first_state[0], Rectangle::default(), renderer, &mut query_op ); } else { ZoomStateOperation::operate( &mut second_state[0], Rectangle::default(), renderer, &mut query_op ); } // Apply the same state to the other pane to ensure complete synchronization let mut apply_op = ZoomStateOperation::new_apply( query_op.scale, query_op.offset ); let success = if target_pane == 0 { ZoomStateOperation::operate( &mut second_state[0], Rectangle::default(), renderer, &mut apply_op ) } else { ZoomStateOperation::operate( &mut first_state[0], Rectangle::default(), renderer, &mut apply_op ) }; if !success { debug_log!("SyncedImageSplit: Could not fully sync state after wheel event"); } else { debug_log!("SyncedImageSplit: Fully synchronized state after wheel event"); } } // Return the status from the target pane that was directly interacted with return target_status.merge(other_status); } } // For non-wheel events, process normally let first_status = self.first.as_widget_mut().on_event( &mut state.children[0], event.clone(), first_layout, cursor, renderer, clipboard, shell, viewport, ); let second_status = self.second.as_widget_mut().on_event( &mut state.children[1], event.clone(), second_layout, cursor, renderer, clipboard, shell, viewport, ); // Handle other split widget specific events let event_status = match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { // Check if click is on the divider first if divider_layout .bounds() .expand(10.0) .contains(cursor.position().unwrap_or_default()) { split_state.dragging = true; debug_log!("Starting divider drag operation"); // Handle double-click for divider reset if let Some(last_click_time) = split_state.last_click_time { let elapsed = last_click_time.elapsed(); if elapsed < Duration::from_millis(self.double_click_threshold_ms as u64) { // Double-click detected split_state.last_click_time = None; split_state.dragging = false; shell.publish((self.on_double_click)(0)); return event::Status::Captured; } else { split_state.last_click_time = Some(Instant::now()); } } else { split_state.last_click_time = Some(Instant::now()); } return event::Status::Captured; } // Initialize panning state if cursor is over an image pane if first_layout.bounds().contains(cursor.position().unwrap_or_default()) { split_state.active_pane_for_pan = Some(0); split_state.pan_start_position = cursor.position().unwrap_or_default(); debug_log!("Starting pan operation in first pane"); // Handle double-click for reset zoom when synced_zoom is true if split_state.synced_zoom { if let Some(last_click_time) = split_state.last_pane_click_time { let elapsed = last_click_time.elapsed(); if elapsed < Duration::from_millis(self.double_click_threshold_ms as u64) { // Double-click detected on pane - reset zoom for both panes debug_log!("Double-click detected in pane - resetting zoom"); split_state.last_pane_click_time = None; // Reset shared zoom state split_state.shared_scale = 1.0; split_state.shared_offset = Vector::default(); // Apply reset to both panes let mut reset_op = ZoomStateOperation::new_apply(1.0, Vector::default()); ZoomStateOperation::operate( &mut state.children[0], Rectangle::default(), renderer, &mut reset_op ); ZoomStateOperation::operate( &mut state.children[1], Rectangle::default(), renderer, &mut reset_op ); return event::Status::Captured; } else { split_state.last_pane_click_time = Some(Instant::now()); } } else { split_state.last_pane_click_time = Some(Instant::now()); } } } else if second_layout.bounds().contains(cursor.position().unwrap_or_default()) { split_state.active_pane_for_pan = Some(1); split_state.pan_start_position = cursor.position().unwrap_or_default(); debug_log!("Starting pan operation in second pane"); // Handle double-click for reset zoom when synced_zoom is true if split_state.synced_zoom { if let Some(last_click_time) = split_state.last_pane_click_time { let elapsed = last_click_time.elapsed(); if elapsed < Duration::from_millis(self.double_click_threshold_ms as u64) { // Double-click detected on pane - reset zoom for both panes debug_log!("Double-click detected in pane - resetting zoom"); split_state.last_pane_click_time = None; // Reset shared zoom state split_state.shared_scale = 1.0; split_state.shared_offset = Vector::default(); // Apply reset to both panes let mut reset_op = ZoomStateOperation::new_apply(1.0, Vector::default()); ZoomStateOperation::operate( &mut state.children[0], Rectangle::default(), renderer, &mut reset_op ); ZoomStateOperation::operate( &mut state.children[1], Rectangle::default(), renderer, &mut reset_op ); return event::Status::Captured; } else { split_state.last_pane_click_time = Some(Instant::now()); } } else { split_state.last_pane_click_time = Some(Instant::now()); } } } // Detect double-click event on the divider if divider_layout .bounds() .expand(10.0) .contains(cursor.position().unwrap_or_default()) { split_state.dragging = true; // Handle double-click detection if let Some(last_click_time) = split_state.last_click_time { let elapsed = last_click_time.elapsed(); if elapsed < Duration::from_millis(self.double_click_threshold_ms as u64) { // Double-click detected split_state.last_click_time = None; let double_click_position = match self.axis { Axis::Horizontal => cursor.position().map(|p| p.y), Axis::Vertical => cursor.position().map(|p| p.x), }; if let Some(position) = double_click_position { self.divider_position = None; split_state.dragging = false; shell.publish((self.on_double_click)(position as u16)); return event::Status::Captured; } } else { // Reset the timer for a new potential double-click split_state.last_click_time = Some(Instant::now()); } } else { split_state.last_click_time = Some(Instant::now()); } event::Status::Captured } else if self.enable_pane_selection { // Only handle pane selection if enabled let is_within_bounds_first = is_cursor_within_bounds(first_layout, cursor, 0, split_state); if is_within_bounds_first { split_state.panes_seleced[0] = !split_state.panes_seleced[0]; shell.publish((self.on_select)(0, split_state.panes_seleced[0])); event::Status::Captured } else { let is_within_bounds_second = is_cursor_within_bounds(second_layout, cursor, 1, split_state); if is_within_bounds_second { split_state.panes_seleced[1] = !split_state.panes_seleced[1]; shell.publish((self.on_select)(1, split_state.panes_seleced[1])); event::Status::Captured } else { event::Status::Ignored } } } else { event::Status::Ignored } }, Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) => { // Always clear dragging and panning state on button release split_state.dragging = false; split_state.active_pane_for_pan = None; debug_log!("Ending drag/pan operation"); event::Status::Ignored }, Event::Mouse(mouse::Event::CursorMoved { position }) => { // Handle divider dragging first if split_state.dragging { let raw_position = match self.axis { Axis::Horizontal => position.y - self.menu_bar_height, Axis::Vertical => position.x, }; // Print debugging info let bounds = layout.bounds(); let min_left = self.min_size_first as f32; let min_right = self.min_size_second as f32; let max_pos = bounds.x + bounds.width - min_right - self.spacing; let min_pos = bounds.x + min_left; debug_log!("Dragging divider - raw_position: {}, bounds: {:?}", raw_position, bounds); debug_log!("Constraints - min_pos: {}, max_pos: {}, spacing: {}", min_pos, max_pos, self.spacing); debug_log!("min_size_first: {}, min_size_second: {}", self.min_size_first, self.min_size_second); shell.publish((self.on_resize)(raw_position as u16)); return event::Status::Captured; } // Then handle panning synchronization if self.synced_zoom && split_state.active_pane_for_pan.is_some() { let active_pane = split_state.active_pane_for_pan.unwrap(); debug_log!("Pan sync: active_pane={}", active_pane); // Get child layouts let mut children = layout.children(); let first_layout = children.next().expect("Missing Split First window"); let _divider_layout = children.next().expect("Missing Split Divider"); let second_layout = children.next().expect("Missing Split Second window"); // Process the event on the active pane first let active_layout = if active_pane == 0 { first_layout } else { second_layout }; let event_status = if active_pane == 0 { self.first.as_widget_mut().on_event( &mut state.children[0], event.clone(), active_layout, cursor, renderer, clipboard, shell, viewport, ) } else { self.second.as_widget_mut().on_event( &mut state.children[1], event.clone(), active_layout, cursor, renderer, clipboard, shell, viewport, ) }; if event_status == event::Status::Captured { // The active pane has processed the event, now query its state let (first_state, second_state) = state.children.split_at_mut(1); // Create a query operation to get the current state let mut query_op = ZoomStateOperation::new_query(); // Query the state from the active pane if active_pane == 0 { ZoomStateOperation::operate( &mut first_state[0], Rectangle::default(), renderer, &mut query_op ); } else { ZoomStateOperation::operate( &mut second_state[0], Rectangle::default(), renderer, &mut query_op ); } // Update the shared state split_state.shared_scale = query_op.scale; split_state.shared_offset = query_op.offset; debug_log!("Syncing pan state: scale={}, offset=({},{})", query_op.scale, query_op.offset.x, query_op.offset.y); // Apply the same state to the other pane let mut apply_op = ZoomStateOperation::new_apply( query_op.scale, query_op.offset ); // Apply to the other pane let success = if active_pane == 0 { ZoomStateOperation::operate( &mut second_state[0], Rectangle::default(), renderer, &mut apply_op ) } else { ZoomStateOperation::operate( &mut first_state[0], Rectangle::default(), renderer, &mut apply_op ) }; if !success { debug_log!("SyncedImageSplit: Could not sync zoom state - target pane doesn't support it"); } return event::Status::Captured; } } // If we're not syncing or no active pane, process normally let first_status = self.first.as_widget_mut().on_event( &mut state.children[0], event.clone(), first_layout, cursor, renderer, clipboard, shell, viewport, ); let second_status = self.second.as_widget_mut().on_event( &mut state.children[1], event.clone(), second_layout, cursor, renderer, clipboard, shell, viewport, ); first_status.merge(second_status) }, // Handle file drop events - retain original functionality #[cfg(any(target_os = "macos", target_os = "windows"))] Event::Window(iced::window::Event::FileDropped(path, position)) => { // Use the position from the event directly instead of cursor position let drop_position = Point::new(position.x as f32, position.y as f32); debug_log!("FileDropped at position: {:?}", drop_position); // Check first pane (index 0) if first_layout.bounds().contains(drop_position) { debug_log!("FileDropped - First pane"); shell.publish((self.on_drop)(0, path[0].to_string_lossy().to_string())); event::Status::Captured } // Check second pane (index 1) else if second_layout.bounds().contains(drop_position) { debug_log!("FileDropped - Second pane"); shell.publish((self.on_drop)(1, path[0].to_string_lossy().to_string())); event::Status::Captured } else { event::Status::Ignored } }, // File drop for Linux #[cfg(target_os = "linux")] Event::Window(iced::window::Event::FileDropped(paths, position)) => { if paths.is_empty() { return event::Status::Ignored; } let drop_position = Point::new(position.x as f32, position.y as f32); let path = &paths[0]; if first_layout.bounds().contains(drop_position) { debug_log!("FileDropped - First pane"); shell.publish((self.on_drop)(0, path.to_string_lossy().to_string())); event::Status::Captured } else if second_layout.bounds().contains(drop_position) { debug_log!("FileDropped - Second pane"); shell.publish((self.on_drop)(1, path.to_string_lossy().to_string())); event::Status::Captured } else { event::Status::Ignored } }, _ => event::Status::Ignored, }; first_status.merge(second_status).merge(event_status) } fn mouse_interaction( &self, state: &Tree, layout: Layout<'_>, cursor: Cursor, viewport: &Rectangle, renderer: &Renderer, ) -> mouse::Interaction { let mut children = layout.children(); let first_layout = children .next() .expect("Graphics: Layout should have a first layout"); let first_mouse_interaction = self.first.as_widget().mouse_interaction( &state.children[0], first_layout, cursor, viewport, renderer, ); let divider_layout = children .next() .expect("Graphics: Layout should have a divider layout"); // Increase the hitbox expansion from 5.0 to 10.0 pixels let divider_mouse_interaction = if divider_layout .bounds().expand(10.0) .contains(cursor.position().unwrap_or_default()) { match self.axis { Axis::Horizontal => mouse::Interaction::ResizingVertically, Axis::Vertical => mouse::Interaction::ResizingHorizontally, } } else { mouse::Interaction::default() }; let second_layout = children .next() .expect("Graphics: Layout should have a second layout"); let second_mouse_interaction = self.second.as_widget().mouse_interaction( &state.children[1], second_layout, cursor, viewport, renderer, ); first_mouse_interaction .max(second_mouse_interaction) .max(divider_mouse_interaction) } fn draw( &self, tree: &Tree, renderer: &mut Renderer, theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, cursor: Cursor, viewport: &Rectangle, ) { // TODO: clipping! let mut children = layout.children(); let bounds = layout.bounds(); let content_layout = layout.children().next().unwrap(); let is_mouse_over = cursor.is_over(bounds); let status = if is_mouse_over { let state = tree.state.downcast_ref::(); if state.dragging { Status::Dragging } else { Status::Hovered } } else { Status::Active }; let style = theme.style(&self.class, status); // Background renderer.fill_quad( renderer::Quad { bounds: content_layout.bounds(), border: style.border, shadow: Shadow::default(), }, style .background .unwrap_or_else(|| Color::TRANSPARENT.into()), ); let first_layout = children .next() .expect("Graphics: Layout should have a first layout"); let bounds_first = first_layout.bounds(); let is_mouse_over_first = cursor.is_over(bounds_first); let status_first = if is_mouse_over_first { let state = tree.state.downcast_ref::(); if state.dragging { Status::Dragging } else { Status::Hovered } } else { Status::Active }; let style_first = theme.style(&self.class, status_first); // First renderer.fill_quad( renderer::Quad { bounds: bounds_first, border: style_first.first_border, shadow: Shadow::default(), }, style_first .first_background .unwrap_or_else(|| Color::TRANSPARENT.into()), ); self.first.as_widget().draw( &tree.children[0], renderer, theme, &renderer::Style::default(), first_layout, cursor, viewport, ); let divider_layout = children .next() .expect("Graphics: Layout should have a divider layout"); // Second let second_layout = children .next() .expect("Graphics: Layout should have a second layout"); let bounds_second = second_layout.bounds(); let is_mouse_over_second = cursor.is_over(bounds_second); let status_second = if is_mouse_over_second { let state = tree.state.downcast_ref::(); if state.dragging { Status::Dragging } else { Status::Hovered } } else { Status::Active }; let style_second = theme.style(&self.class, status_second); renderer.fill_quad( renderer::Quad { bounds: bounds_second, border: style_second.second_border, shadow: Shadow::default(), }, style_second .second_background .unwrap_or_else(|| Color::TRANSPARENT.into()), ); self.second.as_widget().draw( &tree.children[1], renderer, theme, &renderer::Style::default(), second_layout, cursor, viewport, ); let bounds_divider = divider_layout.bounds(); let is_mouse_over_divider = cursor.is_over(bounds_divider.expand(5.0)); let status_divider = if is_mouse_over_divider { let state = tree.state.downcast_ref::(); if state.dragging { Status::Dragging } else { Status::Hovered } } else { Status::Active }; let style_divider = theme.style(&self.class, status_divider); let bounds = divider_layout.bounds(); let is_horizontal = bounds.width >= bounds.height; // Create a modified Rectangle for a thin line, centered within the divider area let thin_rectangle = if is_horizontal { // For horizontal dividers Rectangle { x: bounds.x, y: bounds.y + (bounds.height - 1.0) / 2.0, // Center the 1px line width: bounds.width + 10.0, height: 1.0, } } else { // For vertical dividers Rectangle { x: bounds.x + (bounds.width - 1.0) / 2.0, // Center the 1px line y: bounds.y, width: 1.0, height: bounds.height + 10.0, // `+ 10.0` is needed to make the divider reach the top edge of footer } }; // Draw the divider (thin line) renderer.fill_quad( renderer::Quad { bounds: thin_rectangle, border: Border { color: style_divider.border.color, width: 0.0, radius: Radius::new(0.0), }, shadow: Default::default(), // No shadow }, Background::Color(Color::from_rgb(0.2, 0.2, 0.2)), ); // Draw pane selection status only if enabled if self.enable_pane_selection { let style = theme.style(&self.class, Status::Active); if self.is_selected[0] { renderer.fill_quad( renderer::Quad { bounds: first_layout.bounds(), border: Border { color: style.primary.base.color, width: 1.0, radius: Radius::new(0.0), }, shadow: Default::default(), }, Background::Color(Color::TRANSPARENT), ); } if self.is_selected[1] { renderer.fill_quad( renderer::Quad { bounds: second_layout.bounds(), border: Border { color: style.primary.base.color, width: 1.0, radius: Radius::new(0.0), }, shadow: Default::default(), }, Background::Color(Color::TRANSPARENT), ); } } } fn operate<'b>( &'b self, state: &'b mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation, ) { let split_state = state.state.downcast_mut::(); // Check if synced zoom is enabled before injecting state if self.synced_zoom { // Get child layouts let mut children = layout.children(); let first_layout = children.next().expect("Missing Split First window"); let _divider_layout = children.next().expect("Missing Split Divider"); let second_layout = children.next().expect("Missing Split Second window"); // Split the tree for mutable access to both children let (first_state, second_state) = state.children.split_at_mut(1); // Create a zoom operation with the current shared zoom state let mut zoom_op = ZoomStateOperation { scale: split_state.shared_scale, offset: split_state.shared_offset, query_only: false, }; // Propagate to first child self.first.as_widget().operate( &mut first_state[0], first_layout, renderer, &mut zoom_op, ); // Propagate to second child self.second.as_widget().operate( &mut second_state[0], second_layout, renderer, &mut zoom_op, ); } // Continue with the original operation let mut children = layout.children(); let first_layout = children.next().expect("Missing Split First window"); let _divider_layout = children.next().expect("Missing Split Divider"); let second_layout = children.next().expect("Missing Split Second window"); let (first_state, second_state) = state.children.split_at_mut(1); // Forward the original operation to children self.first.as_widget().operate( &mut first_state[0], first_layout, renderer, operation ); self.second.as_widget().operate( &mut second_state[0], second_layout, renderer, operation ); } fn overlay<'b>( &'b mut self, state: &'b mut Tree, layout: Layout<'_>, renderer: &Renderer, translation: Vector, ) -> Option> { let mut children = layout.children(); let first_layout = children.next()?; let _divider_layout = children.next()?; let second_layout = children.next()?; let first = &mut self.first; let second = &mut self.second; // Not pretty but works to get two mutable references // https://stackoverflow.com/a/30075629 let (first_state, second_state) = state.children.split_at_mut(1); first .as_widget_mut() .overlay(&mut first_state[0], first_layout, renderer, translation) .or_else(|| { second.as_widget_mut().overlay( &mut second_state[0], second_layout, renderer, translation, ) }) } } // Helper function to process a layout and check for cursor position // This function assumes that the first child of the container is the Image widget // TODO: Fix hardcoding fn is_cursor_within_bounds( layout: Layout<'_>, cursor: Cursor, _pane_index: usize, _split_state: &mut State, ) -> bool { if let Some(container_layout) = layout.children().next() { if let Some(image_layout) = container_layout.children().next() { let image_bounds = image_layout.bounds(); if image_bounds.contains(cursor.position().unwrap_or_default()) { return true; } } } false } /// The state of a [`SyncedImageSplit`]. #[derive(Clone, Debug)] pub struct State { /// If the divider is dragged by the user. dragging: bool, last_click_time: Option, last_pane_click_time: Option, panes_seleced: [bool; 2], // Zoom and pan synchronization state synced_zoom: bool, shared_scale: f32, shared_offset: Vector, active_pane_for_pan: Option, pan_start_position: Point, } impl State { /// Creates a new [`State`] for a [`SyncedImageSplit`]. pub fn new() -> Self { Self { dragging: false, last_click_time: None, last_pane_click_time: None, panes_seleced: [false, false], // Initialize zoom and pan state synced_zoom: false, shared_scale: 1.0, shared_offset: Vector::default(), active_pane_for_pan: None, pan_start_position: Point::default(), } } } impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: 'a + Clone, Renderer: 'a + renderer::Renderer, Theme: 'a + Catalog, { fn from(split_pane: SyncedImageSplit<'a, Message, Theme, Renderer>) -> Self { Element::new(split_pane) } } /// Custom operation for synchronizing zoom state between image panes #[derive(Debug)] pub struct ZoomStateOperation { /// The scale factor to apply pub scale: f32, /// The offset for panning pub offset: Vector, /// Whether we're querying (true) or setting (false) the state pub query_only: bool, } impl ZoomStateOperation { pub fn new_query() -> Self { Self { scale: 1.0, offset: Vector::default(), query_only: true, } } pub fn new_apply(scale: f32, offset: Vector) -> Self { Self { scale, offset, query_only: false, } } } // Implement the Operation trait impl widget::Operation for ZoomStateOperation { fn container( &mut self, _id: Option<&widget::Id>, _bounds: Rectangle, _operate: &mut dyn FnMut(&mut dyn widget::Operation), ) { // Empty implementation } } // COMPLETELY SEPARATE STATIC FUNCTION // This is not part of the Operation trait implementation impl ZoomStateOperation { pub fn operate( tree: &mut widget::Tree, _bounds: Rectangle, _renderer: &T, operation: &mut Self, ) -> bool { // Check if the tree's tag matches ImageShaderState's type before attempting downcast if tree.tag == tree::Tag::of::() { // Now it's safe to downcast let shader_state = tree.state.downcast_mut::(); if !operation.query_only { // Apply mode shader_state.scale = operation.scale; shader_state.current_offset = operation.offset; debug_log!("ZoomStateOperation: Applied scale={}, offset=({},{})", operation.scale, operation.offset.x, operation.offset.y); } else { // Query mode operation.scale = shader_state.scale; operation.offset = shader_state.current_offset; debug_log!("ZoomStateOperation: Queried scale={}, offset=({},{})", operation.scale, operation.offset.x, operation.offset.y); } true } else { // This tree doesn't contain an ImageShaderState debug_log!("ZoomStateOperation: Tree doesn't contain ImageShaderState, skipping"); false } } } ================================================ FILE: src/widgets/toggler.rs ================================================ //! Togglers let users make binary choices by toggling a switch. //! //! # Example //! ```no_run //! # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } //! # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; //! # //! use iced::widget::toggler; //! //! struct State { //! is_checked: bool, //! } //! //! enum Message { //! TogglerToggled(bool), //! } //! //! fn view(state: &State) -> Element<'_, Message> { //! toggler(state.is_checked) //! .label("Toggle me!") //! .on_toggle(Message::TogglerToggled) //! .into() //! } //! //! fn update(state: &mut State, message: Message) { //! match message { //! Message::TogglerToggled(is_checked) => { //! state.is_checked = is_checked; //! } //! } //! } //! ``` #[cfg(target_os = "linux")] mod other_os { pub use iced_custom as iced; } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; } #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; use iced::{ alignment, event, touch, advanced::{ layout, mouse, renderer, text, widget, widget::tree::{self, Tree}, Widget, Clipboard, Shell, Layout }, border::Radius, Border, Color, Element, Event, Length, Pixels, Padding, Rectangle, Size, Theme, }; use std::borrow::Cow; #[allow(unused_imports)] use log::{Level, debug, info, warn, error}; use iced_widget::text as widget_text; /// A toggler widget. /// /// # Example /// ```no_run /// # mod iced { pub mod widget { pub use iced_widget::*; } pub use iced_widget::Renderer; pub use iced_widget::core::*; } /// # pub type Element<'a, Message> = iced_widget::core::Element<'a, Message, iced_widget::Theme, iced_widget::Renderer>; /// # /// use iced::widget::toggler; /// /// struct State { /// is_checked: bool, /// } /// /// enum Message { /// TogglerToggled(bool), /// } /// /// fn view(state: &State) -> Element<'_, Message> { /// toggler(state.is_checked) /// .label("Toggle me!") /// .on_toggle(Message::TogglerToggled) /// .into() /// } /// /// fn update(state: &mut State, message: Message) { /// match message { /// Message::TogglerToggled(is_checked) => { /// state.is_checked = is_checked; /// } /// } /// } /// ``` #[allow(missing_debug_implementations)] pub struct Toggler< 'a, Message, Theme = crate::Theme, Renderer = iced::Renderer, > where Theme: Catalog, Renderer: text::Renderer, { is_toggled: bool, on_toggle: Option Message + 'a>>, label: Option>, width: Length, size: f32, text_size: Option, text_line_height: text::LineHeight, text_alignment: alignment::Horizontal, text_shaping: text::Shaping, text_wrapping: text::Wrapping, spacing: f32, padding: Padding, font: Option, class: Theme::Class<'a>, } impl<'a, Message, Theme, Renderer> Toggler<'a, Message, Theme, Renderer> where Theme: Catalog, Renderer: text::Renderer, { /// The default size of a [`Toggler`]. pub const DEFAULT_SIZE: f32 = 16.0; /// Creates a new [`Toggler`]. /// /// It expects: /// * a boolean describing whether the [`Toggler`] is checked or not /// * An optional label for the [`Toggler`] /// * a function that will be called when the [`Toggler`] is toggled. It /// will receive the new state of the [`Toggler`] and must produce a /// `Message`. //pub fn new(is_toggled: bool) -> Self { pub fn new( label: impl Into>, is_toggled: bool, f: F, ) -> Self where F: 'a + Fn(bool) -> Message, { Toggler { is_toggled, on_toggle: Some(Box::new(f)), label: label.into().map(Cow::Owned), width: Length::Shrink, size: Self::DEFAULT_SIZE, //text_size: None, text_size: Some(iced::Pixels(14.0)), text_line_height: text::LineHeight::default(), text_alignment: alignment::Horizontal::Left, text_shaping: text::Shaping::default(), text_wrapping: text::Wrapping::default(), //spacing: Self::DEFAULT_SIZE / 2.0, spacing: 0.0, padding: DEFAULT_PADDING, font: None, class: Theme::default(), } } /// Sets the label of the [`Toggler`]. pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self { self.label = Some(label.into_fragment()); self } /// Sets the message that should be produced when a user toggles /// the [`Toggler`]. /// /// If this method is not called, the [`Toggler`] will be disabled. pub fn on_toggle( mut self, on_toggle: impl Fn(bool) -> Message + 'a, ) -> Self { self.on_toggle = Some(Box::new(on_toggle)); self } /// Sets the message that should be produced when a user toggles /// the [`Toggler`], if `Some`. /// /// If `None`, the [`Toggler`] will be disabled. pub fn on_toggle_maybe( mut self, on_toggle: Option Message + 'a>, ) -> Self { self.on_toggle = on_toggle.map(|on_toggle| Box::new(on_toggle) as _); self } /// Sets the size of the [`Toggler`]. pub fn size(mut self, size: impl Into) -> Self { self.size = size.into().0; self } /// Sets the width of the [`Toggler`]. pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } /// Sets the text size o the [`Toggler`]. pub fn text_size(mut self, text_size: impl Into) -> Self { self.text_size = Some(text_size.into()); self } /// Sets the text [`text::LineHeight`] of the [`Toggler`]. pub fn text_line_height( mut self, line_height: impl Into, ) -> Self { self.text_line_height = line_height.into(); self } /// Sets the horizontal alignment of the text of the [`Toggler`] pub fn text_alignment(mut self, alignment: alignment::Horizontal) -> Self { self.text_alignment = alignment; self } /// Sets the [`text::Shaping`] strategy of the [`Toggler`]. pub fn text_shaping(mut self, shaping: text::Shaping) -> Self { self.text_shaping = shaping; self } /// Sets the [`text::Wrapping`] strategy of the [`Toggler`]. pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self { self.text_wrapping = wrapping; self } /// Sets the spacing between the [`Toggler`] and the text. pub fn spacing(mut self, spacing: impl Into) -> Self { self.spacing = spacing.into().0; self } /// Sets the [`Padding`] of the [`Button`]. pub fn padding>(mut self, padding: P) -> Self { self.padding = padding.into(); self } /// Sets the [`Renderer::Font`] of the text of the [`Toggler`] /// /// [`Renderer::Font`]: crate::core::text::Renderer pub fn font(mut self, font: impl Into) -> Self { self.font = Some(font.into()); self } /// Sets the style of the [`Toggler`]. #[must_use] pub fn style(mut self, style: impl Fn(&Theme, Status) -> Style + 'a) -> Self where Theme::Class<'a>: From>, { self.class = (Box::new(style) as StyleFn<'a, Theme>).into(); self } /// Sets the style class of the [`Toggler`]. #[must_use] pub fn class(mut self, class: impl Into>) -> Self { self.class = class.into(); self } } impl<'a, Message, Theme, Renderer> Widget for Toggler<'a, Message, Theme, Renderer> where Theme: Catalog, Renderer: text::Renderer, { fn tag(&self) -> tree::Tag { tree::Tag::of::>() } fn state(&self) -> tree::State { tree::State::new(widget::text::State::::default()) } fn size(&self) -> Size { Size { width: self.width, height: Length::Shrink, } } fn layout( &self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { // Use the padded helper to add padding layout::padded( limits, self.width, Length::Shrink, // Adjust height as needed self.padding, // Pass the padding value here |limits| { // Use the existing logic for laying out the children layout::next_to_each_other( limits, self.spacing, |_| layout::Node::new(Size::new(2.0 * self.size, self.size)), |limits| { if let Some(label) = self.label.as_deref() { let state = tree .state .downcast_mut::>(); widget::text::layout( state, renderer, limits, self.width, Length::Shrink, label, self.text_line_height, self.text_size, self.font, self.text_alignment, alignment::Vertical::Top, self.text_shaping, self.text_wrapping, ) } else { layout::Node::new(Size::ZERO) } }, ) }, ) } fn on_event( &mut self, _state: &mut Tree, event: Event, layout: Layout<'_>, cursor: mouse::Cursor, _renderer: &Renderer, _clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &Rectangle, ) -> event::Status { let Some(on_toggle) = &self.on_toggle else { return event::Status::Ignored; }; match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { let mouse_over = cursor.is_over(layout.bounds()); if mouse_over { shell.publish(on_toggle(!self.is_toggled)); event::Status::Captured } else { event::Status::Ignored } } _ => event::Status::Ignored, } } fn mouse_interaction( &self, _state: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { if cursor.is_over(layout.bounds()) { if self.on_toggle.is_some() { mouse::Interaction::Pointer } else { mouse::Interaction::NotAllowed } } else { mouse::Interaction::default() } } fn draw( &self, tree: &Tree, renderer: &mut Renderer, theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, ) { /// Makes sure that the border radius of the toggler looks good at every size. const BORDER_RADIUS_RATIO: f32 = 32.0 / 13.0; /// The space ratio between the background Quad and the Toggler bounds, and /// between the background Quad and foreground Quad. const SPACE_RATIO: f32 = 0.05; let mut children = layout.children(); // The first child layout now represents the padded content let padded_content_layout = if let Some(layout) = children.next() { layout } else { warn!("Error: Missing padded content layout"); return; }; // Retrieve the children of the padded content layout let mut padded_children = padded_content_layout.children(); // let toggler_layout = children.next().unwrap(); // => This will cause a runtime panic if the next child layout is missing (None) // Using a workaround to handle this case let toggler_layout = if let Some(layout) = padded_children.next() { layout } else { warn!("Error: Missing toggler layout"); return; }; let bounds = toggler_layout.bounds(); let is_mouse_over = cursor.is_over(layout.bounds()); let status = if self.on_toggle.is_none() { Status::Disabled } else if is_mouse_over { Status::Hovered { is_toggled: self.is_toggled, } } else { Status::Active { is_toggled: self.is_toggled, } }; let style = theme.style(&self.class, status); // Fill the entire widget background let widget_bounds = layout.bounds(); // Bounds of the entire widget renderer.fill_quad( renderer::Quad { bounds: widget_bounds, border: Border { radius: Radius::new(0.0), // No border radius for full background width: 1.0, color: style.widget_background }, ..renderer::Quad::default() }, style.widget_background, // Use the background color from the style ); if self.label.is_some() { // Handle the label layout similar to the toggler layout // let label_layout = children.next().unwrap(); if let Some(label_layout) = padded_children.next() { let state: &widget::text::State = tree.state.downcast_ref(); /*crate::iced::widget::text::draw( renderer, _style, label_layout, state.0.raw(), crate::iced::widget::text::Style::default(), viewport, );*/ widget_text::draw( renderer, _style, label_layout, state.0.raw(), widget_text::Style::default(), viewport, ); } else { warn!("Error: Missing label layout"); return; } } let border_radius = bounds.height / BORDER_RADIUS_RATIO; let space = SPACE_RATIO * bounds.height; let toggler_background_bounds = Rectangle { x: bounds.x + space, y: bounds.y + space, width: bounds.width - (2.0 * space), height: bounds.height - (2.0 * space), }; renderer.fill_quad( renderer::Quad { bounds: toggler_background_bounds, border: Border { radius: border_radius.into(), width: style.background_border_width, color: style.background_border_color, }, ..renderer::Quad::default() }, style.background, ); let toggler_foreground_bounds = Rectangle { x: bounds.x + if self.is_toggled { bounds.width - 2.0 * space - (bounds.height - (4.0 * space)) } else { 2.0 * space }, y: bounds.y + (2.0 * space), width: bounds.height - (4.0 * space), height: bounds.height - (4.0 * space), }; renderer.fill_quad( renderer::Quad { bounds: toggler_foreground_bounds, border: Border { radius: border_radius.into(), width: style.foreground_border_width, color: style.foreground_border_color, }, ..renderer::Quad::default() }, style.foreground, ); } } impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where Message: 'a, Theme: Catalog + 'a, Renderer: text::Renderer + 'a, { fn from( toggler: Toggler<'a, Message, Theme, Renderer>, ) -> Element<'a, Message, Theme, Renderer> { Element::new(toggler) } } /// The default [`Padding`] of a [`Toggler`]. pub(crate) const DEFAULT_PADDING: Padding = Padding { top: 5.0, bottom: 5.0, right: 10.0, left: 10.0, }; /// The possible status of a [`Toggler`]. Same as Button. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { /// The [`Toggler`] can be interacted with. Active { /// Indicates whether the [`Toggler`] is toggled. is_toggled: bool, }, /// The [`Toggler`] is being hovered. Hovered { /// Indicates whether the [`Toggler`] is toggled. is_toggled: bool, }, /// The [`Toggler`] is disabled. Disabled, } /// The appearance of a toggler. #[derive(Debug, Clone, Copy)] pub struct Style { /// The background [`Color`] of the toggler. pub background: Color, /// The width of the background border of the toggler. pub background_border_width: f32, /// The [`Color`] of the background border of the toggler. pub background_border_color: Color, /// The foreground [`Color`] of the toggler. pub foreground: Color, /// The width of the foreground border of the toggler. pub foreground_border_width: f32, /// The [`Color`] of the foreground border of the toggler. pub foreground_border_color: Color, /// The background [`Color`] of the entire widget. pub widget_background: Color, } /// The theme catalog of a [`Toggler`]. pub trait Catalog: Sized { /// The item class of the [`Catalog`]. type Class<'a>; /// The default class produced by the [`Catalog`]. fn default<'a>() -> Self::Class<'a>; /// The [`Style`] of a class with the given status. fn style(&self, class: &Self::Class<'_>, status: Status) -> Style; } /// A styling function for a [`Toggler`]. /// /// This is just a boxed closure: `Fn(&Theme, Status) -> Style`. pub type StyleFn<'a, Theme> = Box Style + 'a>; impl Catalog for Theme { type Class<'a> = StyleFn<'a, Self>; fn default<'a>() -> Self::Class<'a> { Box::new(default) } fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { class(self, status) } } /// The default style of a [`Toggler`]. pub fn default(theme: &Theme, status: Status) -> Style { let palette = theme.extended_palette(); let widget_background = match status { Status::Active { is_toggled: _ } => { palette.background.base.color } Status::Hovered { is_toggled: _ } => { palette.background.weak.color } Status::Disabled => palette.background.weak.color, // Muted color for disabled state }; let background = match status { Status::Active { is_toggled } | Status::Hovered { is_toggled } => { if is_toggled { palette.primary.strong.color } else { palette.background.strong.color } } Status::Disabled => palette.background.weak.color, }; let foreground = match status { Status::Active { is_toggled } | Status::Hovered { is_toggled } => { if is_toggled { palette.primary.strong.text } else { palette.background.base.color } } Status::Disabled => palette.background.base.color, }; Style { widget_background, background, foreground, foreground_border_width: 0.0, foreground_border_color: Color::TRANSPARENT, background_border_width: 0.0, background_border_color: Color::TRANSPARENT, } } ================================================ FILE: src/widgets/viewer.rs ================================================ //! Zoom and pan on an image. #[cfg(target_os = "linux")] mod other_os { //pub use iced; pub use iced_custom as iced; } #[cfg(not(target_os = "linux"))] mod macos { pub use iced_custom as iced; } #[cfg(target_os = "linux")] use other_os::*; #[cfg(not(target_os = "linux"))] use macos::*; use iced::{ event, advanced::{ layout, mouse, renderer, image::{self, FilterMethod}, widget::tree::{self, Tree}, Widget, Clipboard, Shell, Layout, image::Image, }, Element, Event, Length, Pixels, Point, Radians, Vector, Rectangle, Size, ContentFit }; /// A frame that displays an image with the ability to zoom in/out and pan. #[allow(missing_debug_implementations)] pub struct Viewer { padding: f32, width: Length, height: Length, min_scale: f32, max_scale: f32, scale_step: f32, handle: Handle, filter_method: FilterMethod, content_fit: ContentFit, initial_scale: Option, initial_offset: Option, #[cfg(feature = "coco")] pane_index: usize, #[cfg(feature = "coco")] on_zoom_change: Option Message>>, _phantom: std::marker::PhantomData, } impl Viewer { /// Creates a new [`Viewer`] with the given [`State`]. pub fn new>(handle: T) -> Self { Viewer { handle: handle.into(), padding: 0.0, width: Length::Shrink, height: Length::Shrink, min_scale: 0.25, max_scale: 10.0, scale_step: 0.10, filter_method: FilterMethod::default(), content_fit: ContentFit::default(), initial_scale: None, initial_offset: None, #[cfg(feature = "coco")] pane_index: 0, #[cfg(feature = "coco")] on_zoom_change: None, _phantom: std::marker::PhantomData, } } /// Sets the [`FilterMethod`] of the [`Viewer`]. pub fn filter_method(mut self, filter_method: image::FilterMethod) -> Self { self.filter_method = filter_method; self } /// Sets the [`ContentFit`] of the [`Viewer`]. pub fn content_fit(mut self, content_fit: ContentFit) -> Self { self.content_fit = content_fit; self } /// Sets the padding of the [`Viewer`]. pub fn padding(mut self, padding: impl Into) -> Self { self.padding = padding.into().0; self } /// Sets the width of the [`Viewer`]. pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); self } /// Sets the height of the [`Viewer`]. pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); self } /// Sets the max scale applied to the image of the [`Viewer`]. /// /// Default is `10.0` pub fn max_scale(mut self, max_scale: f32) -> Self { self.max_scale = max_scale; self } /// Sets the min scale applied to the image of the [`Viewer`]. /// /// Default is `0.25` pub fn min_scale(mut self, min_scale: f32) -> Self { self.min_scale = min_scale; self } /// Sets the percentage the image of the [`Viewer`] will be scaled by /// when zoomed in / out. /// /// Default is `0.10` pub fn scale_step(mut self, scale_step: f32) -> Self { self.scale_step = scale_step; self } /// Sets the initial zoom scale and pan offset for the [`Viewer`]. /// This is useful for syncing the viewer state with external zoom/pan state. pub fn with_zoom_state(mut self, scale: f32, offset: Vector) -> Self { self.initial_scale = Some(scale); self.initial_offset = Some(offset); self } /// Sets the pane index for COCO feature zoom tracking #[cfg(feature = "coco")] pub fn pane_index(mut self, pane_index: usize) -> Self { self.pane_index = pane_index; self } /// Sets a callback to be called when zoom/pan state changes (for COCO feature) #[cfg(feature = "coco")] pub fn on_zoom_change(mut self, f: F) -> Self where F: 'static + Fn(usize, f32, Vector) -> Message, { self.on_zoom_change = Some(Box::new(f)); self } } impl Widget for Viewer where Renderer: image::Renderer, Handle: Clone, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { let mut state = State::new(); if let Some(scale) = self.initial_scale { state.scale = scale; } if let Some(offset) = self.initial_offset { state.current_offset = offset; } tree::State::new(state) } fn diff(&self, tree: &mut Tree) { // Update the state with new zoom values if they were provided let state = tree.state.downcast_mut::(); if let Some(scale) = self.initial_scale { state.scale = scale; } if let Some(offset) = self.initial_offset { state.current_offset = offset; } } fn size(&self) -> Size { Size { width: self.width, height: self.height, } } fn layout( &self, _tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits, ) -> layout::Node { // The raw w/h of the underlying image let image_size = renderer.measure_image(&self.handle); let image_size = Size::new(image_size.width as f32, image_size.height as f32); // The size to be available to the widget prior to `Shrink`ing let raw_size = limits.resolve(self.width, self.height, image_size); // The uncropped size of the image when fit to the bounds above let full_size = self.content_fit.fit(image_size, raw_size); // Shrink the widget to fit the resized image, if requested let final_size = Size { width: match self.width { Length::Shrink => f32::min(raw_size.width, full_size.width), _ => raw_size.width, }, height: match self.height { Length::Shrink => f32::min(raw_size.height, full_size.height), _ => raw_size.height, }, }; layout::Node::new(final_size) } fn on_event( &mut self, tree: &mut Tree, event: Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, _clipboard: &mut dyn Clipboard, #[allow(unused_variables)] shell: &mut Shell<'_, Msg>, _viewport: &Rectangle, ) -> event::Status { let bounds = layout.bounds(); match event { Event::Mouse(mouse::Event::WheelScrolled { delta }) => { let Some(cursor_position) = cursor.position_over(bounds) else { return event::Status::Ignored; }; match delta { mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => { let state = tree.state.downcast_mut::(); let previous_scale = state.scale; if y < 0.0 && previous_scale > self.min_scale || y > 0.0 && previous_scale < self.max_scale { state.scale = (if y > 0.0 { state.scale * (1.0 + self.scale_step) } else { state.scale / (1.0 + self.scale_step) }) .clamp(self.min_scale, self.max_scale); let scaled_size = scaled_image_size( renderer, &self.handle, state, bounds.size(), self.content_fit, ); let factor = state.scale / previous_scale - 1.0; let cursor_to_center = cursor_position - bounds.center(); let adjustment = cursor_to_center * factor + state.current_offset * factor; state.current_offset = Vector::new( if scaled_size.width > bounds.width { state.current_offset.x + adjustment.x } else { 0.0 }, if scaled_size.height > bounds.height { state.current_offset.y + adjustment.y } else { 0.0 }, ); // Emit zoom change event for COCO feature #[cfg(feature = "coco")] if let Some(ref on_zoom_change) = self.on_zoom_change { shell.publish(on_zoom_change(self.pane_index, state.scale, state.current_offset)); } } } } event::Status::Captured } Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { let Some(cursor_position) = cursor.position_over(bounds) else { return event::Status::Ignored; }; let state = tree.state.downcast_mut::(); let now = std::time::Instant::now(); // Check for double-click using the threshold from CONFIG if let Some(last_click) = state.last_click_time { let threshold_ms = crate::CONFIG.double_click_threshold_ms as u128; if now.duration_since(last_click).as_millis() < threshold_ms { // Double-click detected - reset zoom and pan state.scale = 1.0; state.current_offset = Vector::default(); state.starting_offset = Vector::default(); state.last_click_time = None; // Emit zoom change event for COCO feature #[cfg(feature = "coco")] if let Some(ref on_zoom_change) = self.on_zoom_change { shell.publish(on_zoom_change(self.pane_index, 1.0, Vector::default())); } return event::Status::Captured; } } state.last_click_time = Some(now); state.cursor_grabbed_at = Some(cursor_position); state.starting_offset = state.current_offset; event::Status::Captured } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { let state = tree.state.downcast_mut::(); if state.cursor_grabbed_at.is_some() { state.cursor_grabbed_at = None; event::Status::Captured } else { event::Status::Ignored } } Event::Mouse(mouse::Event::CursorMoved { position }) => { let state = tree.state.downcast_mut::(); if let Some(origin) = state.cursor_grabbed_at { let scaled_size = scaled_image_size( renderer, &self.handle, state, bounds.size(), self.content_fit, ); let hidden_width = (scaled_size.width - bounds.width / 2.0) .max(0.0) .round(); let hidden_height = (scaled_size.height - bounds.height / 2.0) .max(0.0) .round(); let delta = position - origin; let x = if bounds.width < scaled_size.width { (state.starting_offset.x - delta.x) .clamp(-hidden_width, hidden_width) } else { 0.0 }; let y = if bounds.height < scaled_size.height { (state.starting_offset.y - delta.y) .clamp(-hidden_height, hidden_height) } else { 0.0 }; state.current_offset = Vector::new(x, y); // Emit zoom change event for COCO feature when panning #[cfg(feature = "coco")] if let Some(ref on_zoom_change) = self.on_zoom_change { shell.publish(on_zoom_change(self.pane_index, state.scale, state.current_offset)); } event::Status::Captured } else { event::Status::Ignored } } _ => event::Status::Ignored, } } fn mouse_interaction( &self, tree: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, _viewport: &Rectangle, _renderer: &Renderer, ) -> mouse::Interaction { let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); let is_mouse_over = cursor.is_over(bounds); if state.is_cursor_grabbed() { mouse::Interaction::Grabbing } else if is_mouse_over { mouse::Interaction::Grab } else { mouse::Interaction::None } } fn draw( &self, tree: &Tree, renderer: &mut Renderer, _theme: &Theme, _style: &renderer::Style, layout: Layout<'_>, _cursor: mouse::Cursor, _viewport: &Rectangle, ) { let state = tree.state.downcast_ref::(); let bounds = layout.bounds(); let final_size = scaled_image_size( renderer, &self.handle, state, bounds.size(), self.content_fit, ); let _image_size = renderer.measure_image(&self.handle); //log::debug!( // "Viewer draw: measured_image=({},{}), bounds=({:.1},{:.1}), final_size=({:.1},{:.1}), state.scale={:.3}, state.offset=({:.1},{:.1})", // image_size.width, image_size.height, // bounds.width, bounds.height, // final_size.width, final_size.height, // state.scale, // state.current_offset.x, state.current_offset.y //); // // Adjust bounds size and position for padding let padding = 1.0; // Adjust the padding value as needed let padded_bounds = Rectangle { x: bounds.x + padding, y: bounds.y + padding, width: final_size.width - 2.0 * padding, height: final_size.height - 2.0 * padding, }; let _padded_image_size = final_size - Size::new(2.0 * padding, 2.0 * padding); let translation = { let diff_w = bounds.width - final_size.width; let diff_h = bounds.height - final_size.height; let image_top_left = match self.content_fit { ContentFit::None => { Vector::new(diff_w.max(0.0) / 2.0, diff_h.max(0.0) / 2.0) } _ => Vector::new(diff_w / 2.0, diff_h / 2.0), } + Vector::new(padding, padding); let result = image_top_left - state.offset(bounds, final_size); log::debug!( "Viewer translation: diff=({:.1},{:.1}), image_top_left=({:.1},{:.1}), state.offset=({:.1},{:.1}), final_translation=({:.1},{:.1})", diff_w, diff_h, image_top_left.x, image_top_left.y, state.offset(bounds, final_size).x, state.offset(bounds, final_size).y, result.x, result.y ); result }; let drawing_bounds = Rectangle { x: bounds.x, y: bounds.y, width: padded_bounds.width, height: padded_bounds.height, }; let render = |renderer: &mut Renderer| { renderer.with_translation(translation, |renderer| { renderer.draw_image( Image { handle: self.handle.clone(), filter_method: self.filter_method, rotation: Radians(0.0), opacity: 1.0, snap: true, }, drawing_bounds, ); }); }; renderer.with_layer(bounds, render); } } /// The local state of a [`Viewer`]. #[derive(Debug, Clone, Copy)] pub struct State { scale: f32, starting_offset: Vector, current_offset: Vector, cursor_grabbed_at: Option, last_click_time: Option, } impl Default for State { fn default() -> Self { Self { scale: 1.0, starting_offset: Vector::default(), current_offset: Vector::default(), cursor_grabbed_at: None, last_click_time: None, } } } impl State { /// Creates a new [`State`]. pub fn new() -> Self { State::default() } /// Returns the current offset of the [`State`], given the bounds /// of the [`Viewer`] and its image. fn offset(&self, bounds: Rectangle, image_size: Size) -> Vector { let hidden_width = (image_size.width - bounds.width / 2.0).max(0.0).round(); let hidden_height = (image_size.height - bounds.height / 2.0).max(0.0).round(); Vector::new( self.current_offset.x.clamp(-hidden_width, hidden_width), self.current_offset.y.clamp(-hidden_height, hidden_height), ) } /// Returns if the cursor is currently grabbed by the [`Viewer`]. pub fn is_cursor_grabbed(&self) -> bool { self.cursor_grabbed_at.is_some() } } impl<'a, Message, Theme, Renderer, Handle> From> for Element<'a, Message, Theme, Renderer> where Renderer: 'a + image::Renderer, Message: 'a, Handle: Clone + 'a, { fn from(viewer: Viewer) -> Element<'a, Message, Theme, Renderer> { Element::new(viewer) } } /// Returns the bounds of the underlying image, given the bounds of /// the [`Viewer`]. Scaling will be applied and original aspect ratio /// will be respected. pub fn scaled_image_size( renderer: &Renderer, handle: &::Handle, state: &State, bounds: Size, content_fit: ContentFit, ) -> Size where Renderer: image::Renderer, { let Size { width, height } = renderer.measure_image(handle); let image_size = Size::new(width as f32, height as f32); let adjusted_fit = content_fit.fit(image_size, bounds); Size::new( adjusted_fit.width * state.scale, adjusted_fit.height * state.scale, ) } ================================================ FILE: src/window_state.rs ================================================ use iced_winit::winit; use winit::dpi::{PhysicalPosition, PhysicalSize}; use winit::monitor::MonitorHandle; use log::error; use crate::settings::WindowState; use crate::app::DataViewer; /// Constant for define window is in the monitor const VISIBLE_SIZE: i32 = 30; /// Returns current window is in monitor /// /// true: current window position /// false: closest monitor position pub fn get_window_visible( current_position: PhysicalPosition, current_size: PhysicalSize, monitor: Option, ) -> (bool, PhysicalPosition) { let mut cx = current_position.x; let mut cy = current_position.y; let mut visible = true; if let Some(mh) = monitor { let mut plus_area = mh.position(); let mut minus_area = mh.position(); plus_area.x += mh.size().width as i32 - VISIBLE_SIZE; plus_area.y += mh.size().height as i32 - VISIBLE_SIZE; minus_area.x -= current_size.width as i32 - VISIBLE_SIZE; minus_area.y -= current_size.height as i32 - VISIBLE_SIZE; if cx >= plus_area.x || cy >= plus_area.y || cx <= minus_area.x || cy <= minus_area.y { visible = false; cx = mh.position().x; cy = mh.position().y; } } (visible, PhysicalPosition::new(cx, cy)) } /// Queries NSWindow.isZoomed() directly via objc2. /// Reliable when called outside of zoom animation (i.e. at save time). #[cfg(target_os = "macos")] pub fn query_is_zoomed(window: &winit::window::Window) -> bool { use objc2_app_kit::NSView; use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; let Ok(handle) = window.window_handle() else { return false }; let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { return false }; let ns_view = appkit.ns_view.as_ptr() as *mut objc2::runtime::AnyObject; let ns_view: &NSView = unsafe { &*(ns_view as *const NSView) }; let Some(ns_window) = ns_view.window() else { return false }; ns_window.isZoomed() } /// Saves the current window state from the iced application state to disk. /// On macOS, queries NSWindow.isZoomed() directly for authoritative state /// (the heuristic-based app.window_state may be stale mid-animation). pub fn save_window_state_to_disk(app: &DataViewer, window: &winit::window::Window) { let mut settings = crate::settings::UserSettings::load(None); // On macOS, query isZoomed() at save time — authoritative post-animation. // If zoom was missed during WindowResized (isZoomed() unreliable mid-animation), // this corrects the state and uses position_before_transition as the windowed position. #[cfg(target_os = "macos")] let (window_state, pos_source) = { let is_zoomed = query_is_zoomed(window); if is_zoomed { // Zoomed: use position_before_transition (the pre-zoom windowed position) (WindowState::Maximized, app.position_before_transition) } else { (WindowState::Window, app.last_windowed_position) } }; #[cfg(not(target_os = "macos"))] let (window_state, pos_source) = { let _ = window; // suppress unused warning (app.window_state, app.last_windowed_position) }; let mut pos = pos_source; let tuple = get_window_visible(pos, app.window_size, app.last_monitor.clone()); if !tuple.0 { pos = tuple.1; } settings.window_position_x = pos.x; settings.window_position_y = pos.y; if window_state == WindowState::Window { settings.window_width = app.window_size.width; settings.window_height = app.window_size.height; } settings.window_state = window_state; if let Err(e) = settings.save() { error!("Failed to save window state: {e}"); } } /// macOS: zoom to maximize if needed, and register a termination observer /// to persist window state on Cmd+Q. /// /// Winit creates a native Quit menu item with Cmd+Q → `[NSApp terminate:]`, /// which bypasses winit's event loop (CloseRequested never fires, keyboard /// handler never sees Cmd+Q). The observer reads the NSWindow frame directly /// at termination time — authoritative and animation-free. #[cfg(target_os = "macos")] pub fn setup_macos_window(window: &winit::window::Window) { use objc2_app_kit::{NSView, NSScreen}; use objc2_foundation::{MainThreadMarker, NSNotificationCenter, NSNotification}; use block2::RcBlock; use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; let Ok(handle) = window.window_handle() else { return }; let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { return }; let ns_view = appkit.ns_view.as_ptr() as *mut objc2::runtime::AnyObject; let ns_view: &NSView = unsafe { &*(ns_view as *const NSView) }; let Some(ns_window) = ns_view.window() else { return }; // Zoom to maximize if needed. zoom() saves the current (unzoomed) frame // to _savedFrame, so double-click title bar unzoom works correctly. if crate::config::CONFIG.window_state == WindowState::Maximized { ns_window.zoom(None); } // Register NSApplicationWillTerminateNotification observer. // This fires on Cmd+Q before exit(), letting us save state. let ns_win = ns_window.clone(); let block = RcBlock::new(move |_: std::ptr::NonNull| { let mut settings = crate::settings::UserSettings::load(None); if ns_win.isZoomed() { // Only save the state flag. The windowed position/size in settings // is correct from the last Focused(false) save or from CONFIG. settings.window_state = WindowState::Maximized; } else { let frame = ns_win.frame(); let scale = ns_win.backingScaleFactor(); // Convert position: macOS bottom-left origin (points) // → top-left origin (physical pixels) let screen_height = MainThreadMarker::new() .and_then(|mtm| NSScreen::mainScreen(mtm)) .map(|s| s.frame().size.height) .unwrap_or(0.0); let x = (frame.origin.x * scale) as i32; let y = ((screen_height - frame.origin.y - frame.size.height) * scale) as i32; settings.window_position_x = x; settings.window_position_y = y; // Inner size (content area) in physical pixels if let Some(content_view) = ns_win.contentView() { let content = content_view.frame(); settings.window_width = (content.size.width * scale) as u32; settings.window_height = (content.size.height * scale) as u32; } settings.window_state = WindowState::Window; } if let Err(e) = settings.save() { log::error!("Failed to save window state on termination: {e}"); } }); let center = unsafe { NSNotificationCenter::defaultCenter() }; let observer = unsafe { center.addObserverForName_object_queue_usingBlock( Some(objc2_app_kit::NSApplicationWillTerminateNotification), None, None, &block, ) }; // Keep the observer alive for the entire process lifetime std::mem::forget(observer); }