diff --git a/src/app.rs b/src/app.rs index 396cbf2..eee36c6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,6 +40,11 @@ pub enum Message { RandomFolderTree, ScrollToCurrentThumbnail, FolderSelected(Option), + /// 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), 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) { + 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, +) -> (Vec, 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 { + 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 { + 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 { 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 = 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(_))); + } +} diff --git a/src/codec/image_crate.rs b/src/codec/image_crate.rs index aa225bb..75cb24c 100644 --- a/src/codec/image_crate.rs +++ b/src/codec/image_crate.rs @@ -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, diff --git a/src/codec/mod.rs b/src/codec/mod.rs index 0a54bc2..c10af40 100644 --- a/src/codec/mod.rs +++ b/src/codec/mod.rs @@ -272,6 +272,123 @@ fn rotate_rgba_180(src: &[u8], w: u32, h: u32) -> (Vec, u32, u32) { (dst, w, h) } +fn mirror_rgba_horizontal(src: &[u8], w: u32, h: u32) -> (Vec, 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, 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, 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 { + 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 { + 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()); + } +} diff --git a/src/main.rs b/src/main.rs index 2aa463d..ef66f23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) }