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
================================================
# 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