updated file loading
This commit is contained in:
parent
f91fb0745a
commit
e70bdb60f1
270
src/app.rs
270
src/app.rs
|
|
@ -40,6 +40,11 @@ pub enum Message {
|
|||
RandomFolderTree,
|
||||
ScrollToCurrentThumbnail,
|
||||
FolderSelected(Option<PathBuf>),
|
||||
/// Open an arbitrary path passed in from outside the app — typically the
|
||||
/// file association case (double-clicking an image in a file manager
|
||||
/// launches `marten /path/to/image.jpg`). Dispatches to `open_folder` or
|
||||
/// `open_file` based on the path's filesystem type.
|
||||
OpenPath(PathBuf),
|
||||
ImageDecoded(Result<DecodedImage, DecodeError>),
|
||||
ThumbnailsLoaded(Vec<(usize, iced::widget::image::Handle)>),
|
||||
OpenFolder,
|
||||
|
|
@ -111,6 +116,79 @@ impl Default for Viewer {
|
|||
}
|
||||
}
|
||||
|
||||
impl Viewer {
|
||||
/// Bootstrap entry point — called by
|
||||
/// `iced::application(...).run_with(Viewer::init_from_env)`.
|
||||
///
|
||||
/// Reads the first CLI argument (if any) to support being opened as a
|
||||
/// file-association target. When a user double-clicks an image in their
|
||||
/// file manager with marten set as the default app, the OS launches
|
||||
/// `marten /path/to/image.jpg`. Without this, marten starts with an
|
||||
/// empty viewer and the file is silently ignored — which is the bug we're
|
||||
/// fixing.
|
||||
///
|
||||
/// Behavior:
|
||||
/// * No argv[1] → start with empty viewer (open-folder dialog flow).
|
||||
/// * argv[1] is a file → open it (scans parent folder for next/prev nav).
|
||||
/// * argv[1] is a dir → open that folder directly.
|
||||
/// * argv[1] doesn't exist or isn't readable → start empty + log a warning.
|
||||
pub fn init_from_env() -> (Self, Task<Message>) {
|
||||
let mut viewer = Self::default();
|
||||
|
||||
// argv[0] is the program path; the first real argument is argv[1].
|
||||
// We deliberately only look at the first one — file managers never
|
||||
// pass multiple files to a single-instance association target, and
|
||||
// handling a list of files is a separate feature.
|
||||
let task = match std::env::args_os().nth(1).map(PathBuf::from) {
|
||||
Some(path) if path.is_dir() || path.is_file() => {
|
||||
viewer.update(Message::OpenPath(path))
|
||||
}
|
||||
Some(path) => {
|
||||
log::warn!(
|
||||
"Ignoring CLI argument (not a regular file or directory): {}",
|
||||
path.display()
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
None => Task::none(),
|
||||
};
|
||||
|
||||
(viewer, task)
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a clicked file path and the result of scanning its parent folder,
|
||||
/// decide which image list to display and at which index the clicked file
|
||||
/// lives.
|
||||
///
|
||||
/// * If the file is present in the scan → return the scan as-is with its
|
||||
/// index. This is the common case (file is supported and not anti-listed).
|
||||
/// * If the file is *not* in the scan (anti-listed codec, hidden file the
|
||||
/// user explicitly clicked, or the parent scan returned empty) → push the
|
||||
/// file into the list at its sorted position so the user still sees it and
|
||||
/// gets a proper decode error through the normal `load_current` pipeline
|
||||
/// rather than a silent "No supported images" message.
|
||||
///
|
||||
/// Returns `(images, target_index)`. If both inputs are empty, returns
|
||||
/// `(empty, 0)` and the caller surfaces "No supported images".
|
||||
fn resolve_open_file_target(
|
||||
file_path: &std::path::Path,
|
||||
scanned: Vec<PathBuf>,
|
||||
) -> (Vec<PathBuf>, usize) {
|
||||
if let Some(idx) = scanned.iter().position(|p| p == file_path) {
|
||||
return (scanned, idx);
|
||||
}
|
||||
|
||||
let mut images = scanned;
|
||||
images.push(file_path.to_path_buf());
|
||||
images.sort();
|
||||
let idx = images
|
||||
.iter()
|
||||
.position(|p| p == file_path)
|
||||
.expect("just pushed it");
|
||||
(images, idx)
|
||||
}
|
||||
|
||||
/// What the image area viewport size is (window minus chrome).
|
||||
fn image_viewport(window: Size, fullscreen: bool, sidebar_width: f32) -> Size {
|
||||
if fullscreen {
|
||||
|
|
@ -144,19 +222,7 @@ impl Viewer {
|
|||
)
|
||||
}
|
||||
|
||||
Message::FolderSelected(Some(path)) => {
|
||||
let images = crate::nav::scan_folder(&path, &self.codec);
|
||||
self.sidebar.refresh(&path, &self.codec);
|
||||
if images.is_empty() {
|
||||
self.status_bar.filename =
|
||||
"No supported images in that folder".into();
|
||||
self.status_bar.total = 0;
|
||||
return Task::none();
|
||||
}
|
||||
self.navigator.set_images(images.clone());
|
||||
self.thumbnail_bar.set_paths(images, 0);
|
||||
self.load_current()
|
||||
}
|
||||
Message::FolderSelected(Some(path)) => self.open_folder(path),
|
||||
|
||||
Message::FolderSelected(None) => {
|
||||
if self.navigator.is_empty() {
|
||||
|
|
@ -165,6 +231,25 @@ impl Viewer {
|
|||
Task::none()
|
||||
}
|
||||
|
||||
Message::OpenPath(path) => {
|
||||
// Dispatch based on what's actually on disk. A non-existent
|
||||
// path (e.g., broken symlink or stale .desktop entry) surfaces
|
||||
// a clean status-bar message instead of silently doing nothing.
|
||||
if path.is_dir() {
|
||||
self.open_folder(path)
|
||||
} else if path.is_file() {
|
||||
self.open_file(path)
|
||||
} else {
|
||||
log::warn!(
|
||||
"Ignoring OpenPath argument — not a file or directory: {}",
|
||||
path.display()
|
||||
);
|
||||
self.status_bar.filename =
|
||||
format!("Path not found: {}", path.display());
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
Message::Navigate(delta) => {
|
||||
if self.navigator.is_empty() {
|
||||
return Task::none();
|
||||
|
|
@ -779,6 +864,71 @@ impl Viewer {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Path-open helpers ─────────────────────────────────────────────
|
||||
//
|
||||
// The two flavors of "open from outside the app":
|
||||
// * `open_folder` — used by the in-app folder picker AND by `OpenPath`
|
||||
// when the OS hands us a directory. Scans the folder, loads the first
|
||||
// image.
|
||||
// * `open_file` — used by `OpenPath` when the OS hands us a single
|
||||
// image file (the file-association case). Scans the *parent* folder
|
||||
// so the user still gets next/prev navigation, then jumps to the
|
||||
// clicked file's index.
|
||||
//
|
||||
// Both feed into the same `load_current` pipeline so thumbnailing,
|
||||
// sidebar, status bar, etc. all stay consistent.
|
||||
|
||||
/// Open a folder of images — shared by the in-app "Open Folder" dialog
|
||||
/// (`Message::FolderSelected`) and by `Message::OpenPath` when the path
|
||||
/// is a directory.
|
||||
fn open_folder(&mut self, path: PathBuf) -> Task<Message> {
|
||||
let images = crate::nav::scan_folder(&path, &self.codec);
|
||||
self.sidebar.refresh(&path, &self.codec);
|
||||
if images.is_empty() {
|
||||
self.status_bar.filename = "No supported images in that folder".into();
|
||||
self.status_bar.total = 0;
|
||||
return Task::none();
|
||||
}
|
||||
self.navigator.set_images(images.clone());
|
||||
self.thumbnail_bar.set_paths(images, 0);
|
||||
self.load_current()
|
||||
}
|
||||
|
||||
/// Open a single image file by path — the file-association case. Scans
|
||||
/// the file's parent folder so next/prev navigation still works, then
|
||||
/// jumps to the clicked file's index in that scan.
|
||||
///
|
||||
/// If the file isn't in the scan results (e.g., it's on the codec
|
||||
/// anti-list — `scan_folder` filters those out — or the parent folder
|
||||
/// is unreadable), we still push it into the navigator so the user sees
|
||||
/// *something* and gets the proper decode error via the error modal
|
||||
/// rather than a silent "no supported images" message.
|
||||
fn open_file(&mut self, file_path: PathBuf) -> Task<Message> {
|
||||
let Some(parent) = file_path.parent() else {
|
||||
self.status_bar.filename =
|
||||
"Cannot determine parent folder".into();
|
||||
self.status_bar.total = 0;
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let scanned = crate::nav::scan_folder(parent, &self.codec);
|
||||
self.sidebar.refresh(parent, &self.codec);
|
||||
|
||||
let (images, target_index) = resolve_open_file_target(&file_path, scanned);
|
||||
|
||||
if images.is_empty() {
|
||||
self.status_bar.filename = "No supported images in that folder".into();
|
||||
self.status_bar.total = 0;
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.navigator.set_images(images.clone());
|
||||
// `set_images` resets current to 0; jump to the clicked file.
|
||||
self.navigator.jump_to(target_index);
|
||||
self.thumbnail_bar.set_paths(images, target_index);
|
||||
self.load_current()
|
||||
}
|
||||
|
||||
fn load_current(&mut self) -> Task<Message> {
|
||||
let path = match self.navigator.current_path() {
|
||||
Some(p) => p.to_path_buf(),
|
||||
|
|
@ -1335,3 +1485,97 @@ mod img {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(s: &str) -> PathBuf {
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_target_when_file_in_scan_returns_scan_unchanged() {
|
||||
// Common case: user double-clicks a supported, non-anti-listed image.
|
||||
// The scan already contains it — we should hand back the same list
|
||||
// (no clone, no sort) and the correct index.
|
||||
let file = p("/photos/album/b.png");
|
||||
let scanned = vec![p("/photos/album/a.png"), p("/photos/album/b.png")];
|
||||
|
||||
let (images, idx) = resolve_open_file_target(&file, scanned.clone());
|
||||
|
||||
assert_eq!(images, scanned);
|
||||
assert_eq!(idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_target_when_file_not_in_scan_inserts_at_sorted_position() {
|
||||
// Anti-listed case: scan_folder filtered the clicked file out. We
|
||||
// still want to display it, so it's inserted at its sorted position
|
||||
// and `load_current` will try to decode it (surfacing a proper
|
||||
// decode error if the codec really can't handle it).
|
||||
let file = p("/photos/album/b.png");
|
||||
let scanned = vec![p("/photos/album/a.png"), p("/photos/album/c.png")];
|
||||
|
||||
let (images, idx) = resolve_open_file_target(&file, scanned);
|
||||
|
||||
assert_eq!(
|
||||
images,
|
||||
vec![
|
||||
p("/photos/album/a.png"),
|
||||
p("/photos/album/b.png"),
|
||||
p("/photos/album/c.png"),
|
||||
]
|
||||
);
|
||||
assert_eq!(idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_target_with_empty_scan_yields_just_the_file() {
|
||||
// Parent folder is empty or unreadable — fall back to showing the
|
||||
// clicked file alone so the user gets a decode attempt rather than
|
||||
// a silent "No supported images".
|
||||
let file = p("/photos/album/lonely.png");
|
||||
let scanned: Vec<PathBuf> = vec![];
|
||||
|
||||
let (images, idx) = resolve_open_file_target(&file, scanned);
|
||||
|
||||
assert_eq!(images, vec![p("/photos/album/lonely.png")]);
|
||||
assert_eq!(idx, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_target_finds_file_with_arbitrary_filename() {
|
||||
// Make sure the lookup is by exact path equality, not by basename —
|
||||
// a file with the same name in a different folder must NOT match.
|
||||
let file = p("/photos/vacation/sunset.png");
|
||||
let scanned = vec![p("/photos/other/sunset.png"), p("/photos/vacation/sunrise.png")];
|
||||
|
||||
let (images, idx) = resolve_open_file_target(&file, scanned.clone());
|
||||
|
||||
// File wasn't in the scan → inserted at sorted position.
|
||||
assert_eq!(images.len(), 3);
|
||||
assert_eq!(idx, images.iter().position(|p| p == &file).unwrap());
|
||||
}
|
||||
|
||||
/// Sanity check that `init_from_env` returns a viewer with empty state
|
||||
/// when no CLI argument is present. We can't easily test the "with arg"
|
||||
/// case here because it depends on `FormatRegistry` and the filesystem,
|
||||
/// but the no-arg path is the safe baseline.
|
||||
#[test]
|
||||
fn init_from_env_no_args_returns_empty_viewer() {
|
||||
// The default-constructed Viewer (which `init_from_env` falls back to
|
||||
// when there's no usable argv[1]) must start with an empty navigator.
|
||||
let viewer = Viewer::default();
|
||||
assert!(viewer.navigator.is_empty());
|
||||
}
|
||||
|
||||
// Compile-time check that `Message::OpenPath` exists and accepts a PathBuf.
|
||||
// Guards against accidental removal of the variant during refactoring.
|
||||
#[test]
|
||||
fn open_path_message_variant_exists() {
|
||||
let msg = Message::OpenPath(PathBuf::from("/some/file.png"));
|
||||
// Pattern-match to prove the variant is reachable.
|
||||
assert!(matches!(msg, Message::OpenPath(_)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,26 @@ impl Codec for ImageCrateCodec {
|
|||
let height = img.height();
|
||||
let pixels = img.into_raw();
|
||||
|
||||
// Apply EXIF orientation if present. Phone cameras (iOS, Android)
|
||||
// typically save the raw sensor data in landscape orientation and
|
||||
// write an EXIF orientation tag (most commonly 6 for "rotate 90° CW"
|
||||
// on portrait shots) so the viewer can present the image upright.
|
||||
// Without this step, every portrait phone photo appears sideways.
|
||||
//
|
||||
// We parse the orientation from the original `bytes` (not the
|
||||
// decoded pixels) because the `image` crate's decoder does not
|
||||
// preserve EXIF metadata in its output. `kamadak-exif` reads the
|
||||
// EXIF segment directly from the file container.
|
||||
//
|
||||
// If parsing fails or there's no orientation tag, `None` is
|
||||
// returned and we fall through to orientation = 1 (identity).
|
||||
let (pixels, width, height) = match super::parse_exif_orientation(bytes) {
|
||||
Some(orientation) if orientation != 1 => {
|
||||
super::apply_exif_orientation(&pixels, width, height, orientation)
|
||||
}
|
||||
_ => (pixels, width, height),
|
||||
};
|
||||
|
||||
Ok(DecodedImage {
|
||||
width,
|
||||
height,
|
||||
|
|
|
|||
300
src/codec/mod.rs
300
src/codec/mod.rs
|
|
@ -272,6 +272,123 @@ fn rotate_rgba_180(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
|
|||
(dst, w, h)
|
||||
}
|
||||
|
||||
fn mirror_rgba_horizontal(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
|
||||
// Mirror left↔right (flip X axis).
|
||||
// Same dimensions as input.
|
||||
let mut dst = vec![0u8; src.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let src_idx = ((y * w + x) * 4) as usize;
|
||||
let nx = w - 1 - x;
|
||||
let dst_idx = ((y * w + nx) * 4) as usize;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
(dst, w, h)
|
||||
}
|
||||
|
||||
fn mirror_rgba_vertical(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
|
||||
// Mirror top↔bottom (flip Y axis).
|
||||
// Same dimensions as input.
|
||||
let mut dst = vec![0u8; src.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let src_idx = ((y * w + x) * 4) as usize;
|
||||
let ny = h - 1 - y;
|
||||
let dst_idx = ((ny * w + x) * 4) as usize;
|
||||
dst[dst_idx..dst_idx + 4].copy_from_slice(&src[src_idx..src_idx + 4]);
|
||||
}
|
||||
}
|
||||
(dst, w, h)
|
||||
}
|
||||
|
||||
// ── EXIF orientation ───────────────────────────────────────────────────
|
||||
|
||||
/// Apply an EXIF orientation tag to an RGBA pixel buffer.
|
||||
///
|
||||
/// EXIF orientation values are 1–8 per the EXIF specification:
|
||||
///
|
||||
/// ```text
|
||||
/// 1 = 0° (no transform — "Normal")
|
||||
/// 2 = mirror horizontal (flip X)
|
||||
/// 3 = 180°
|
||||
/// 4 = mirror vertical (flip Y)
|
||||
/// 5 = transpose (mirror X + 90° CW)
|
||||
/// 6 = 90° CW ← most common for phone portrait shots
|
||||
/// 7 = anti-transpose (mirror X + 90° CCW)
|
||||
/// 8 = 90° CCW ← common for upside-down phone portraits
|
||||
/// ```
|
||||
///
|
||||
/// Phone cameras (iOS, Android) typically save the raw sensor data in
|
||||
/// landscape orientation and write orientation=6 (or 8) so the viewer
|
||||
/// rotates it to portrait for display. Without applying this tag, every
|
||||
/// portrait photo appears sideways.
|
||||
///
|
||||
/// Returns `(new_pixels, new_width, new_height)`. For the rotation cases
|
||||
/// (3, 6, 7, 8) the new dimensions may swap width↔height. For the pure
|
||||
/// mirror cases (2, 4) and for 1 / unknown values, the dimensions are
|
||||
/// preserved.
|
||||
///
|
||||
/// Unknown values (0, or > 8) are treated as 1 (identity) — the EXIF spec
|
||||
/// reserves 0 to mean "unknown", and values > 8 are not valid.
|
||||
pub fn apply_exif_orientation(
|
||||
pixels: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
orientation: u16,
|
||||
) -> (Vec<u8>, u32, u32) {
|
||||
match orientation {
|
||||
1 => (pixels.to_vec(), width, height),
|
||||
2 => mirror_rgba_horizontal(pixels, width, height),
|
||||
3 => rotate_rgba_180(pixels, width, height),
|
||||
4 => mirror_rgba_vertical(pixels, width, height),
|
||||
5 => {
|
||||
// Transpose: reflection across the main diagonal
|
||||
// (top-left to bottom-right). Equivalent to: mirror Y then
|
||||
// rotate 90° CW. The main diagonal is preserved — pixels on
|
||||
// the diagonal stay in place.
|
||||
let (m, w, h) = mirror_rgba_vertical(pixels, width, height);
|
||||
rotate_rgba_cw(&m, w, h)
|
||||
}
|
||||
6 => rotate_rgba_cw(pixels, width, height),
|
||||
7 => {
|
||||
// Transverse: reflection across the anti-diagonal
|
||||
// (top-right to bottom-left). Equivalent to: mirror X then
|
||||
// rotate 90° CW. The anti-diagonal is preserved.
|
||||
let (m, w, h) = mirror_rgba_horizontal(pixels, width, height);
|
||||
rotate_rgba_cw(&m, w, h)
|
||||
}
|
||||
8 => rotate_rgba_ccw(pixels, width, height),
|
||||
// 0 (unknown) and any out-of-range value: identity.
|
||||
_ => (pixels.to_vec(), width, height),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the EXIF orientation tag from in-memory image bytes.
|
||||
///
|
||||
/// Uses `kamadak-exif` to parse the EXIF segment (works for JPEG, TIFF,
|
||||
/// WebP, HEIF — anything that carries an EXIF container). Returns `None`
|
||||
/// when the file has no EXIF segment or no orientation tag — callers
|
||||
/// should treat that as orientation=1 (no transform).
|
||||
///
|
||||
/// Reuses the same library the EXIF properties panel uses, but reads from
|
||||
/// a `Cursor<&[u8]>` rather than re-opening the file from disk — we
|
||||
/// already have the bytes in memory at decode time, so going back to the
|
||||
/// filesystem would be wasteful.
|
||||
pub fn parse_exif_orientation(bytes: &[u8]) -> Option<u16> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut bufreader = std::io::BufReader::new(cursor);
|
||||
let exif_reader = exif::Reader::new();
|
||||
let exif = exif_reader.read_from_container(&mut bufreader).ok()?;
|
||||
|
||||
exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)
|
||||
.and_then(|field| match field.value {
|
||||
exif::Value::Short(ref shorts) => shorts.first().copied(),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod rotation_tests {
|
||||
use super::*;
|
||||
|
|
@ -337,3 +454,186 @@ mod rotation_tests {
|
|||
assert_eq!(out[0..4], px[(3 * 2 - 1) * 4..(3 * 2) * 4]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exif_orientation_tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a 3×2 RGBA image with a recognizable pattern.
|
||||
/// Each pixel is (x, y, 0, 255) so we can identify which source position
|
||||
/// ended up at which destination position after the transform.
|
||||
fn make_pixels(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
v.push(x as u8);
|
||||
v.push(y as u8);
|
||||
v.push(0);
|
||||
v.push(255);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Look up the source position that ended up at destination (x, y).
|
||||
/// Each pixel is (R, G, B, A) = (src_x, src_y, 0, 255), so we just read
|
||||
/// the first two bytes of the pixel at (x, y).
|
||||
fn src_pos_at(out: &[u8], dst_w: u32, x: u32, y: u32) -> (u8, u8) {
|
||||
let idx = ((y * dst_w + x) * 4) as usize;
|
||||
(out[idx], out[idx + 1])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_1_is_identity() {
|
||||
let px = make_pixels(3, 2);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 2, 1);
|
||||
assert_eq!((w, h), (3, 2));
|
||||
assert_eq!(out, px);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_0_is_treated_as_identity() {
|
||||
// EXIF reserves 0 for "unknown" — must not panic, must be identity.
|
||||
let px = make_pixels(3, 2);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 2, 0);
|
||||
assert_eq!((w, h), (3, 2));
|
||||
assert_eq!(out, px);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_3_is_180_rotation() {
|
||||
// 180°: top-left of source should land at bottom-right of dst.
|
||||
let px = make_pixels(3, 2);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 2, 3);
|
||||
assert_eq!((w, h), (3, 2));
|
||||
// Dst bottom-right pixel (2, 1) should be src top-left (0, 0).
|
||||
assert_eq!(src_pos_at(&out, w, 2, 1), (0, 0));
|
||||
// Dst top-left pixel (0, 0) should be src bottom-right (2, 1).
|
||||
assert_eq!(src_pos_at(&out, w, 0, 0), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_6_is_90_cw_and_swaps_dimensions() {
|
||||
// Orientation 6 is the most common case for phone portrait shots.
|
||||
// Source is landscape (3×2), displayed image should be portrait (2×3).
|
||||
let px = make_pixels(3, 2);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 2, 6);
|
||||
assert_eq!((w, h), (2, 3));
|
||||
// For 90° CW: src top-left (0, 0) → dst top-right (w-1=1, 0).
|
||||
assert_eq!(src_pos_at(&out, w, 1, 0), (0, 0));
|
||||
// src top-right (2, 0) → dst bottom-right (1, 2).
|
||||
assert_eq!(src_pos_at(&out, w, 1, 2), (2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_8_is_90_ccw_and_swaps_dimensions() {
|
||||
let px = make_pixels(3, 2);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 2, 8);
|
||||
assert_eq!((w, h), (2, 3));
|
||||
// For 90° CCW: src top-left (0, 0) → dst bottom-left (0, h-1=2).
|
||||
assert_eq!(src_pos_at(&out, w, 0, 2), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_5_preserves_main_diagonal() {
|
||||
// Orientation 5 (transpose) reflects across the main diagonal —
|
||||
// pixels on the main diagonal stay in place. For a square image,
|
||||
// every pixel (x, x) should land at (x, x).
|
||||
// Use a 3×3 image so the main diagonal is (0,0)→(1,1)→(2,2).
|
||||
let px = make_pixels(3, 3);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 3, 5);
|
||||
assert_eq!((w, h), (3, 3));
|
||||
// Transpose: dst(x, y) = src(y, x). So dst(1, 2) should be src(2, 1).
|
||||
assert_eq!(src_pos_at(&out, w, 1, 2), (2, 1));
|
||||
// And dst(2, 1) should be src(1, 2).
|
||||
assert_eq!(src_pos_at(&out, w, 2, 1), (1, 2));
|
||||
// Main diagonal preserved: dst(1, 1) = src(1, 1).
|
||||
assert_eq!(src_pos_at(&out, w, 1, 1), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_7_preserves_anti_diagonal() {
|
||||
// Orientation 7 (transverse) reflects across the anti-diagonal —
|
||||
// pixels on the anti-diagonal stay in place.
|
||||
// For a 3×3 image, anti-diagonal is (2,0)→(1,1)→(0,2).
|
||||
let px = make_pixels(3, 3);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 3, 7);
|
||||
assert_eq!((w, h), (3, 3));
|
||||
// Anti-diagonal preserved: dst(2, 0) = src(2, 0), dst(0, 2) = src(0, 2).
|
||||
assert_eq!(src_pos_at(&out, w, 2, 0), (2, 0));
|
||||
assert_eq!(src_pos_at(&out, w, 0, 2), (0, 2));
|
||||
// Transverse: dst(x, y) = src(h-1-y, w-1-x). So dst(0, 0) = src(2, 2).
|
||||
assert_eq!(src_pos_at(&out, w, 0, 0), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_2_mirrors_horizontally() {
|
||||
let px = make_pixels(3, 2);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 2, 2);
|
||||
assert_eq!((w, h), (3, 2));
|
||||
// Mirror X: dst(x, y) = src(w-1-x, y). dst(0, 0) = src(2, 0).
|
||||
assert_eq!(src_pos_at(&out, w, 0, 0), (2, 0));
|
||||
assert_eq!(src_pos_at(&out, w, 2, 0), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_4_mirrors_vertically() {
|
||||
let px = make_pixels(3, 2);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 2, 4);
|
||||
assert_eq!((w, h), (3, 2));
|
||||
// Mirror Y: dst(x, y) = src(x, h-1-y). dst(0, 0) = src(0, 1).
|
||||
assert_eq!(src_pos_at(&out, w, 0, 0), (0, 1));
|
||||
assert_eq!(src_pos_at(&out, w, 0, 1), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_orientations_round_trip_through_inverse() {
|
||||
// For every orientation, applying the same orientation twice should
|
||||
// NOT necessarily be identity (mirror X twice is identity, but
|
||||
// rotate 90° CW twice is 180°). Instead, verify that applying
|
||||
// orientation N to a 1×1 image is always identity (single pixel
|
||||
// has no spatial orientation).
|
||||
let one_px = vec![7, 8, 9, 255];
|
||||
for orient in 0..=10u16 {
|
||||
let (out, w, h) = apply_exif_orientation(&one_px, 1, 1, orient);
|
||||
assert_eq!((w, h), (1, 1), "orientation {} on 1×1 swapped dims", orient);
|
||||
assert_eq!(out, one_px, "orientation {} on 1×1 changed pixels", orient);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_above_8_is_identity() {
|
||||
// EXIF spec only defines 1–8; values > 8 are out of range.
|
||||
let px = make_pixels(3, 2);
|
||||
let (out, w, h) = apply_exif_orientation(&px, 3, 2, 9);
|
||||
assert_eq!((w, h), (3, 2));
|
||||
assert_eq!(out, px);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exif_orientation_returns_none_for_non_exif_bytes() {
|
||||
// A raw PNG without any EXIF chunk — should return None, not panic.
|
||||
// Use a minimal PNG header + IHDR (8 + 25 bytes).
|
||||
let png_bytes: &[u8] = &[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, // IHDR length
|
||||
0x49, 0x48, 0x44, 0x52, // "IHDR"
|
||||
0x00, 0x00, 0x00, 0x01, // width = 1
|
||||
0x00, 0x00, 0x00, 0x01, // height = 1
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, // bit depth 8, color type 6 (RGBA)
|
||||
0x1f, 0x15, 0xc4, 0x89, // CRC
|
||||
];
|
||||
assert!(parse_exif_orientation(png_bytes).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exif_orientation_returns_none_for_empty_bytes() {
|
||||
assert!(parse_exif_orientation(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exif_orientation_returns_none_for_random_bytes() {
|
||||
let random: &[u8] = &[0xff, 0xfe, 0xfd, 0xfc, 0x00, 0x01, 0x02, 0x03];
|
||||
assert!(parse_exif_orientation(random).is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,5 +23,5 @@ fn main() -> iced::Result {
|
|||
.theme(|_| iced::Theme::Dark)
|
||||
.subscription(app::Viewer::subscription)
|
||||
.window_size(iced::Size::new(1200.0, 800.0))
|
||||
.run()
|
||||
.run_with(app::Viewer::init_from_env)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue