updated file loading

This commit is contained in:
Jeremy Anderson 2026-08-19 23:33:25 -04:00
parent f91fb0745a
commit e70bdb60f1
4 changed files with 578 additions and 14 deletions

View File

@ -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(_)));
}
}

View File

@ -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,

View File

@ -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 18 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 18; 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());
}
}

View File

@ -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)
}