From 823dfe9fa2c7facfaea730d3c2fd79457261193f Mon Sep 17 00:00:00 2001 From: wfx Date: Sat, 10 Jan 2026 11:46:07 +0100 Subject: [PATCH] Implement comprehensive metadata extraction for raster images with EXIF support and display in the right panel. New features: - Extract basic metadata (filename, format, resolution, file size, color type) - Parse EXIF data (camera, date, exposure, aperture, ISO, focal length, GPS) - Display metadata in collapsible right panel (toggle with 'i' key) - Auto-refresh metadata on document navigation Changes by file: Cargo.toml, Cargo.lock: - Add kamadak-exif dependency for EXIF parsing i18n/en/noctua.ftl: - Add translation strings for all metadata labels src/app/document/meta.rs: - New module for metadata types (BasicMeta, ExifMeta, DocumentMeta) - Extraction logic with EXIF parsing via kamadak-exif - Helper methods for formatted display (resolution, file size, camera, GPS) src/app/document/mod.rs: - Re-export meta module src/app/document/{raster,vector,portable}.rs: - Add extract_metadata() method stubs (full impl for raster) src/app/document/file.rs: - Reset metadata on document change src/app/message.rs: - Add ToggleRightPanel and RefreshMetadata messages src/app/model.rs: - Add metadata: Option field - Add show_right_panel: bool field src/app/update.rs: - Handle panel toggle and metadata refresh - Auto-refresh metadata on navigation when panel visible src/app/view/panels.rs: - Implement right_panel() with metadata display - Conditional sections for basic info and EXIF data src/app/view/canvas.rs: - Integrate right panel into layout" --- Cargo.lock | 269 +++++++++++++++++------------------ Cargo.toml | 2 + i18n/en/noctua.ftl | 22 +++ src/app/document/file.rs | 16 +++ src/app/document/meta.rs | 265 ++++++++++++++++++++++++++++++++++ src/app/document/mod.rs | 9 ++ src/app/document/portable.rs | 6 + src/app/document/raster.rs | 7 + src/app/document/vector.rs | 6 + src/app/message.rs | 3 + src/app/model.rs | 7 + src/app/update.rs | 26 ++++ src/app/view/canvas.rs | 4 +- src/app/view/panels.rs | 127 +++++++++++++++-- 14 files changed, 616 insertions(+), 153 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 312c717..e640e56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,9 +134,9 @@ checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" [[package]] name = "aligned" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "377e4c0ba83e4431b10df45c1d4666f178ea9c552cac93e60c3a88bf32785923" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" dependencies = [ "as-slice", ] @@ -286,7 +286,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -343,7 +343,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", - "zbus 5.12.0", + "zbus 5.13.0", ] [[package]] @@ -363,7 +363,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", - "zbus 5.12.0", + "zbus 5.13.0", ] [[package]] @@ -515,7 +515,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -550,7 +550,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -626,7 +626,7 @@ dependencies = [ "derive_utils", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -839,7 +839,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -913,9 +913,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.51" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", "jobserver", @@ -991,7 +991,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1224,7 +1224,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1239,16 +1239,16 @@ dependencies = [ "tokio", "tracing", "xdg", - "zbus 5.12.0", + "zbus 5.13.0", ] [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1282,9 +1282,9 @@ dependencies = [ [[package]] name = "cosmic-settings-daemon" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#70ed219735e312ac8cc3f592a01fa8023f36939b" +source = "git+https://github.com/pop-os/dbus-settings-bindings#87c3c35666b926a24a1e8045fd70be2db1145e34" dependencies = [ - "zbus 5.12.0", + "zbus 5.13.0", ] [[package]] @@ -1313,7 +1313,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "almost", "cosmic-config", @@ -1445,7 +1445,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1456,7 +1456,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1494,7 +1494,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1505,7 +1505,7 @@ checksum = "ccfae181bab5ab6c5478b2ccb69e4c68a02f8c3ec72f6616bfec9dbc599d2ee0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1565,7 +1565,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1678,7 +1678,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1721,7 +1721,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1856,7 +1856,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -1879,9 +1879,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "flate2" @@ -2026,7 +2026,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2138,7 +2138,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2480,7 +2480,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.113", + "syn 2.0.114", "unic-langid", ] @@ -2494,7 +2494,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -2524,7 +2524,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "dnd", "iced_accessibility", @@ -2542,7 +2542,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "accesskit", "accesskit_winit", @@ -2551,7 +2551,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "bitflags 2.10.0", "bytes", @@ -2576,7 +2576,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "futures", "iced_core", @@ -2602,7 +2602,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -2624,7 +2624,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -2636,7 +2636,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "bytes", "cosmic-client-toolkit", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "bytemuck", "cosmic-text", @@ -2668,7 +2668,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "as-raw-xcb-connection", "bitflags 2.10.0", @@ -2699,7 +2699,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -2719,7 +2719,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -2911,9 +2911,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -2956,7 +2956,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3031,9 +3031,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "log", @@ -3044,13 +3044,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3191,14 +3191,14 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic.git#a9f64c33ce9159485be5dad1ce07ccf7c12399d5" +source = "git+https://github.com/pop-os/libcosmic.git#b9c24d24212a865977db4871efc13ff890055648" dependencies = [ "apply", "ashpd 0.12.0", @@ -3240,7 +3240,7 @@ dependencies = [ "tracing", "unicode-segmentation", "url", - "zbus 5.12.0", + "zbus 5.13.0", ] [[package]] @@ -3604,19 +3604,6 @@ dependencies = [ "memoffset 0.7.1", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases 0.2.1", - "libc", - "memoffset 0.9.1", -] - [[package]] name = "noctua" version = "0.1.0" @@ -3628,6 +3615,7 @@ dependencies = [ "i18n-embed", "i18n-embed-fl", "image", + "kamadak-exif", "libcosmic", "log", "open", @@ -3699,7 +3687,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -3761,7 +3749,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4118,7 +4106,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4152,7 +4140,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4284,7 +4272,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4297,7 +4285,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4341,7 +4329,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4518,14 +4506,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -4538,7 +4526,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "version_check", "yansi", ] @@ -4559,7 +4547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -4597,9 +4585,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -4939,7 +4927,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.113", + "syn 2.0.114", "walkdir", ] @@ -5094,14 +5082,14 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "indexmap", "itoa", @@ -5119,7 +5107,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -5444,9 +5432,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.113" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -5461,7 +5449,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -5533,7 +5521,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -5544,7 +5532,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -5685,7 +5673,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -5775,7 +5763,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -5915,14 +5903,15 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -6074,7 +6063,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -6498,7 +6487,7 @@ checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -6509,7 +6498,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -6520,7 +6509,7 @@ checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -6531,7 +6520,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -7093,7 +7082,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "synstructure", ] @@ -7115,7 +7104,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix 0.26.4", + "nix", "once_cell", "ordered-stream", "rand 0.8.5", @@ -7135,9 +7124,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.12.0" +version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "7515214ab069b46f614dee52c1256015cdc1a0b441ed612118e2871014956741" dependencies = [ "async-broadcast 0.7.2", "async-executor", @@ -7153,8 +7142,9 @@ dependencies = [ "futures-core", "futures-lite 2.6.1", "hex", - "nix 0.30.1", + "libc", "ordered-stream", + "rustix 1.1.3", "serde", "serde_repr", "tokio", @@ -7163,9 +7153,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 0.7.14", - "zbus_macros 5.12.0", - "zbus_names 4.2.0", - "zvariant 5.8.0", + "zbus_macros 5.13.0", + "zbus_names 4.3.1", + "zvariant 5.9.0", ] [[package]] @@ -7184,17 +7174,17 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "04f54d8a5b4e9c46cf4a9732da4899b12851b5df952fc8deda23aca1d6f3e26c" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.113", - "zbus_names 4.2.0", - "zvariant 5.8.0", - "zvariant_utils 3.2.1", + "syn 2.0.114", + "zbus_names 4.3.1", + "zvariant 5.9.0", + "zvariant_utils 3.3.0", ] [[package]] @@ -7210,14 +7200,13 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", "winnow 0.7.14", - "zvariant 5.8.0", + "zvariant 5.9.0", ] [[package]] @@ -7228,22 +7217,22 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] @@ -7263,7 +7252,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", "synstructure", ] @@ -7298,14 +7287,14 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.113", + "syn 2.0.114", ] [[package]] name = "zmij" -version = "1.0.11" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb2c125bd7365735bebeb420ccb880265ed2d2bddcbcd49f597fdfe6bd5e577" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" [[package]] name = "zune-core" @@ -7362,17 +7351,17 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.8.0" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "788ca131e3757991e4b9fe9f7b78ae302749ed96093ff60858a1f4732b04b164" dependencies = [ "endi", "enumflags2", "serde", "url", "winnow 0.7.14", - "zvariant_derive 5.8.0", - "zvariant_utils 3.2.1", + "zvariant_derive 5.9.0", + "zvariant_utils 3.3.0", ] [[package]] @@ -7390,15 +7379,15 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "0e69a2f6b221a6fec9bd6bcc77c19360cca106f92a5fd948b8aa17d2339c7505" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.113", - "zvariant_utils 3.2.1", + "syn 2.0.114", + "zvariant_utils 3.3.0", ] [[package]] @@ -7414,13 +7403,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.113", + "syn 2.0.114", "winnow 0.7.14", ] diff --git a/Cargo.toml b/Cargo.toml index ec66672..0b3a32a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ categories = ["gui", "multimedia::graphics", "multimedia::images"] # Error handling anyhow = "1" +kamadak-exif = "0.5.5" + # Async / concurrency futures-util = "0.3.31" tokio = { version = "1.48.0", features = ["full"] } diff --git a/i18n/en/noctua.ftl b/i18n/en/noctua.ftl index 3a7f872..f4aaa52 100644 --- a/i18n/en/noctua.ftl +++ b/i18n/en/noctua.ftl @@ -37,3 +37,25 @@ scale = Scale ## Error messages error-failed-to-open = Failed to open “{ $path }”. error-unsupported-format = Unsupported file format. + +# Metadata panel +metadata = Metadata +file-name = File +format = Format +resolution = Resolution +file-size = Size +color-type = Color + +# EXIF data +exif-data = EXIF Data +camera = Camera +date-taken = Date +exposure = Exposure +aperture = Aperture +iso = ISO +focal-length = Focal +gps = GPS + +# States +loading-metadata = Loading... +no-document = No document diff --git a/src/app/document/file.rs b/src/app/document/file.rs index bc40796..3747bc8 100644 --- a/src/app/document/file.rs +++ b/src/app/document/file.rs @@ -91,6 +91,8 @@ fn load_document_into_model(model: &mut AppModel, path: &Path) { match open_document(path.to_path_buf()) { Ok(doc) => { model.document = Some(doc); + // Reset cached metadata so it gets reloaded when panel is visible. + model.metadata = None; model.current_path = Some(path.to_path_buf()); model.clear_error(); @@ -182,3 +184,17 @@ pub fn navigate_prev(model: &mut AppModel) { load_document_into_model(model, &path); } } +// --------------------------------------------------------------------------- +// File metadata helpers +// --------------------------------------------------------------------------- + +/// Retrieve the file size in bytes. Returns 0 if the file cannot be accessed. +pub fn file_size(path: &Path) -> u64 { + fs::metadata(path).map(|m| m.len()).unwrap_or(0) +} + +/// Read raw bytes from a file for metadata extraction (e.g., EXIF). +/// Returns None if the file cannot be read. +pub fn read_file_bytes(path: &Path) -> Option> { + fs::read(path).ok() +} diff --git a/src/app/document/meta.rs b/src/app/document/meta.rs index fb6ef8c..162012e 100644 --- a/src/app/document/meta.rs +++ b/src/app/document/meta.rs @@ -1,2 +1,267 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/app/document/meta.rs +// +// Document metadata extraction (basic info and EXIF). + +use std::io::Cursor; +use std::path::Path; + +use image::DynamicImage; +use exif::{In, Reader as ExifReader, Tag, Value}; + +use super::file; + +/// Basic document metadata (always available). +#[derive(Debug, Clone)] +pub struct BasicMeta { + /// File name (without path). + pub file_name: String, + /// Full file path. + pub file_path: String, + /// Image format as string (e.g., "PNG", "JPEG", "PDF"). + pub format: String, + /// Width in pixels. + pub width: u32, + /// Height in pixels. + pub height: u32, + /// File size in bytes. + pub file_size: u64, + /// Color type description (e.g., "RGBA8", "RGB8", "Grayscale"). + pub color_type: String, +} + +impl BasicMeta { + /// Format file size as human-readable string. + pub fn file_size_display(&self) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if self.file_size >= GB { + format!("{:.2} GB", self.file_size as f64 / GB as f64) + } else if self.file_size >= MB { + format!("{:.2} MB", self.file_size as f64 / MB as f64) + } else if self.file_size >= KB { + format!("{:.1} KB", self.file_size as f64 / KB as f64) + } else { + format!("{} B", self.file_size) + } + } + + /// Format resolution as "W × H". + pub fn resolution_display(&self) -> String { + format!("{} × {}", self.width, self.height) + } +} + +/// EXIF metadata (optional, mainly for JPEG/TIFF). +#[derive(Debug, Clone, Default)] +pub struct ExifMeta { + pub camera_make: Option, + pub camera_model: Option, + pub date_time: Option, + pub exposure_time: Option, + pub f_number: Option, + pub iso: Option, + pub focal_length: Option, + pub gps_latitude: Option, + pub gps_longitude: Option, +} + +impl ExifMeta { + /// Combined camera make and model for display. + pub fn camera_display(&self) -> Option { + match (&self.camera_make, &self.camera_model) { + (Some(make), Some(model)) => { + if model.starts_with(make) { + Some(model.clone()) + } else { + Some(format!("{} {}", make, model)) + } + } + (Some(make), None) => Some(make.clone()), + (None, Some(model)) => Some(model.clone()), + (None, None) => None, + } + } + + /// Format GPS coordinates for display. + pub fn gps_display(&self) -> Option { + match (self.gps_latitude, self.gps_longitude) { + (Some(lat), Some(lon)) => Some(format!("{:.5}, {:.5}", lat, lon)), + _ => None, + } + } +} + +/// Complete document metadata container. +#[derive(Debug, Clone)] +pub struct DocumentMeta { + pub basic: BasicMeta, + pub exif: Option, +} + +// --------------------------------------------------------------------------- +// Extraction functions +// --------------------------------------------------------------------------- + +/// Extract basic metadata common to all document types. +fn extract_basic_meta( + path: &Path, + width: u32, + height: u32, + format: &str, + color_type: String, +) -> BasicMeta { + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let file_path = path.to_string_lossy().to_string(); + let file_size = file::file_size(path); + + BasicMeta { + file_name, + file_path, + format: format.to_string(), + width, + height, + file_size, + color_type, + } +} + +/// Extract EXIF metadata from file bytes. +fn extract_exif_from_bytes(data: &[u8]) -> Option { + let mut cursor = Cursor::new(data); + let exif = ExifReader::new().read_from_container(&mut cursor).ok()?; + + let mut meta = ExifMeta::default(); + + // Camera info. + if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) { + meta.camera_make = field.display_value().to_string().into(); + } + if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) { + meta.camera_model = field.display_value().to_string().into(); + } + + // Date/time. + if let Some(field) = exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) { + meta.date_time = Some(field.display_value().to_string()); + } else if let Some(field) = exif.get_field(Tag::DateTime, In::PRIMARY) { + meta.date_time = Some(field.display_value().to_string()); + } + + // Exposure settings. + if let Some(field) = exif.get_field(Tag::ExposureTime, In::PRIMARY) { + meta.exposure_time = Some(field.display_value().to_string()); + } + if let Some(field) = exif.get_field(Tag::FNumber, In::PRIMARY) { + meta.f_number = Some(format!("f/{}", field.display_value())); + } + if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY) { + if let Value::Short(ref vals) = field.value { + if let Some(&iso) = vals.first() { + meta.iso = Some(iso as u32); + } + } + } + if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) { + meta.focal_length = Some(field.display_value().to_string()); + } + + // GPS coordinates. + meta.gps_latitude = extract_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef); + meta.gps_longitude = extract_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef); + + Some(meta) +} + +/// Extract a GPS coordinate (latitude or longitude) from EXIF data. +fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option { + let field = exif.get_field(coord_tag, In::PRIMARY)?; + + let degrees = match &field.value { + Value::Rational(rats) if rats.len() >= 3 => { + let d = rats[0].to_f64(); + let m = rats[1].to_f64(); + let s = rats[2].to_f64(); + d + m / 60.0 + s / 3600.0 + } + _ => return None, + }; + + // Check reference (N/S or E/W) for sign. + let sign = if let Some(ref_field) = exif.get_field(ref_tag, In::PRIMARY) { + let ref_str = ref_field.display_value().to_string(); + if ref_str.contains('S') || ref_str.contains('W') { + -1.0 + } else { + 1.0 + } + } else { + 1.0 + }; + + Some(degrees * sign) +} + +/// Determine color type string from DynamicImage. +fn color_type_string(img: &DynamicImage) -> String { + use image::DynamicImage::*; + match img { + ImageLuma8(_) => "Grayscale 8-bit".to_string(), + ImageLumaA8(_) => "Grayscale+Alpha 8-bit".to_string(), + ImageRgb8(_) => "RGB 8-bit".to_string(), + ImageRgba8(_) => "RGBA 8-bit".to_string(), + ImageLuma16(_) => "Grayscale 16-bit".to_string(), + ImageLumaA16(_) => "Grayscale+Alpha 16-bit".to_string(), + ImageRgb16(_) => "RGB 16-bit".to_string(), + ImageRgba16(_) => "RGBA 16-bit".to_string(), + ImageRgb32F(_) => "RGB 32-bit float".to_string(), + ImageRgba32F(_) => "RGBA 32-bit float".to_string(), + _ => "Unknown".to_string(), + } +} + +/// Determine format string from file extension. +fn format_from_extension(path: &Path) -> String { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_uppercase()) + .unwrap_or_else(|| "Unknown".to_string()) +} + +// --------------------------------------------------------------------------- +// Public builder functions for each document type +// --------------------------------------------------------------------------- + +/// Build metadata for a raster document. +pub fn build_raster_meta(path: &Path, img: &DynamicImage, width: u32, height: u32) -> DocumentMeta { + let format = format_from_extension(path); + let color_type = color_type_string(img); + let basic = extract_basic_meta(path, width, height, &format, color_type); + + // Try to extract EXIF (mainly for JPEG/TIFF). + let exif = file::read_file_bytes(path).and_then(|bytes| extract_exif_from_bytes(&bytes)); + + DocumentMeta { basic, exif } +} + +/// Build metadata for a vector document. +pub fn build_vector_meta(path: &Path, width: u32, height: u32) -> DocumentMeta { + let basic = extract_basic_meta(path, width, height, "SVG", "Vector".to_string()); + + DocumentMeta { basic, exif: None } +} + +/// Build metadata for a portable document. +pub fn build_portable_meta(path: &Path, width: u32, height: u32, page_count: u32) -> DocumentMeta { + let format = format!("PDF ({} pages)", page_count); + let basic = extract_basic_meta(path, width, height, &format, "Rendered".to_string()); + + DocumentMeta { basic, exif: None } +} diff --git a/src/app/document/mod.rs b/src/app/document/mod.rs index b440ec3..0a08be1 100644 --- a/src/app/document/mod.rs +++ b/src/app/document/mod.rs @@ -100,4 +100,13 @@ impl DocumentContent { DocumentContent::Portable(doc) => doc.dimensions(), } } + /// Extract metadata from the document. + /// This may involve file I/O for EXIF data, so call lazily. + pub fn extract_meta(&self) -> meta::DocumentMeta { + match self { + DocumentContent::Raster(doc) => doc.extract_meta(), + DocumentContent::Vector(doc) => doc.extract_meta(), + DocumentContent::Portable(doc) => doc.extract_meta(), + } + } } diff --git a/src/app/document/portable.rs b/src/app/document/portable.rs index 0acf00d..129f5d5 100644 --- a/src/app/document/portable.rs +++ b/src/app/document/portable.rs @@ -62,4 +62,10 @@ impl PortableDocument { // self.rendered = render_page_to_dynamic(...); // self.refresh_handle(); } + /// Extract metadata for this portable document. + pub fn extract_meta(&self) -> super::meta::DocumentMeta { + let (width, height) = self.dimensions(); + + super::meta::build_portable_meta(&self.path, width, height, self.page_count) + } } diff --git a/src/app/document/raster.rs b/src/app/document/raster.rs index c12a617..119d359 100644 --- a/src/app/document/raster.rs +++ b/src/app/document/raster.rs @@ -62,4 +62,11 @@ impl RasterDocument { )) } } + /// Extract metadata for this raster document. + pub fn extract_meta(&self) -> super::meta::DocumentMeta { + let path = self.path.as_deref().unwrap_or(std::path::Path::new("")); + let (width, height) = self.dimensions(); + + super::meta::build_raster_meta(path, &self.image, width, height) + } } diff --git a/src/app/document/vector.rs b/src/app/document/vector.rs index b2dea19..f093720 100644 --- a/src/app/document/vector.rs +++ b/src/app/document/vector.rs @@ -45,4 +45,10 @@ impl VectorDocument { // TODO: re-render SVG to DynamicImage and rebuild handle. // Update self.width and self.height accordingly. } + /// Extract metadata for this vector document. + pub fn extract_meta(&self) -> super::meta::DocumentMeta { + let (width, height) = self.dimensions(); + + super::meta::build_vector_meta(&self.path, width, height) + } } diff --git a/src/app/message.rs b/src/app/message.rs index fd6ab97..5a80983 100644 --- a/src/app/message.rs +++ b/src/app/message.rs @@ -20,6 +20,9 @@ pub enum AppMessage { NextDocument, PrevDocument, + /// Refresh metadata (e.g., when panel becomes visible or document changes). + RefreshMetadata, + /// Basic view / panel toggles. ToggleLeftPanel, ToggleRightPanel, diff --git a/src/app/model.rs b/src/app/model.rs index 7924c2a..a09c2d6 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; use crate::app::document::DocumentContent; +use crate::app::document::meta::DocumentMeta; + use crate::config::AppConfig; /// How the document is currently fitted into the window. @@ -51,6 +53,10 @@ pub struct AppModel { /// Currently opened document (raster/vector/portable). pub document: Option, + /// Cached metadata for the current document. + /// Loaded lazily when the right panel is opened. + pub metadata: Option, + /// Path of the currently opened document, if any. pub current_path: Option, @@ -84,6 +90,7 @@ impl AppModel { Self { config, document: None, + metadata: None, current_path: None, folder_entries: Vec::new(), current_index: None, diff --git a/src/app/update.rs b/src/app/update.rs index 8ae5b86..af98bd2 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -17,14 +17,26 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { // ===== File / navigation ========================================================== AppMessage::OpenPath(path) => { document::file::open_single_file(model, &path); + // Refresh metadata if panel is visible. + if model.show_right_panel { + refresh_metadata(model); + } } AppMessage::NextDocument => { document::file::navigate_next(model); + // Refresh metadata if panel is visible. + if model.show_right_panel { + refresh_metadata(model); + } } AppMessage::PrevDocument => { document::file::navigate_prev(model); + // Refresh metadata if panel is visible. + if model.show_right_panel { + refresh_metadata(model); + } } // ===== Panels ===================================================================== @@ -33,6 +45,10 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { } AppMessage::ToggleRightPanel => { model.show_right_panel = !model.show_right_panel; + // Load metadata lazily when panel becomes visible. + if model.show_right_panel && model.metadata.is_none() { + refresh_metadata(model); + } } // ===== View / zoom =============================================================== @@ -102,6 +118,11 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { } } + // ===== Metadata ================================================================== + AppMessage::RefreshMetadata => { + refresh_metadata(model); + } + // ===== Error handling ============================================================ AppMessage::ShowError(msg) => { model.set_error(msg); @@ -139,3 +160,8 @@ fn current_zoom(model: &AppModel) -> f32 { ViewMode::Custom(z) => z, } } + +/// Refresh metadata from the current document. +fn refresh_metadata(model: &mut AppModel) { + model.metadata = model.document.as_ref().map(|doc| doc.extract_meta()); +} diff --git a/src/app/view/canvas.rs b/src/app/view/canvas.rs index a289fde..702f8c9 100644 --- a/src/app/view/canvas.rs +++ b/src/app/view/canvas.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/app/view/canvas.rs // -// Center canvas for displaying the current document. - +/// Renders the center canvas area with the current document. +// use cosmic::iced::{Alignment, Length}; use cosmic::widget::{container, image, text, Column, Row}; use cosmic::Element; diff --git a/src/app/view/panels.rs b/src/app/view/panels.rs index 6c07c6e..cbe0068 100644 --- a/src/app/view/panels.rs +++ b/src/app/view/panels.rs @@ -12,8 +12,55 @@ use crate::app::model::ViewMode; use crate::app::{AppMessage, AppModel}; /// Top header bar (global actions, toggles). -pub fn header(_model: &AppModel) -> Element<'_, AppMessage> { - let content = Row::new().spacing(8).align_y(Alignment::Center); +pub fn header(model: &AppModel) -> Element<'_, AppMessage> { + // Left panel toggle button. + let left_toggle = widget::button::icon(widget::icon::from_name(if model.show_left_panel { + "sidebar-show-left-symbolic" + } else { + "sidebar-show-left-symbolic" + })) + .on_press(AppMessage::ToggleLeftPanel); + + // Right panel toggle button. + let right_toggle = widget::button::icon(widget::icon::from_name(if model.show_right_panel { + "sidebar-show-right-symbolic" + } else { + "sidebar-show-right-symbolic" + })) + .on_press(AppMessage::ToggleRightPanel); + + // File name display (centered). + let file_name = model + .current_path + .as_ref() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(""); + + let title = Text::new(file_name); + + // Spacer to push title to center and right_toggle to the right. + let left_section = Row::new() + .spacing(8) + .align_y(Alignment::Center) + .push(left_toggle); + + let center_section = Container::new(title) + .width(Length::Fill) + .align_x(Alignment::Center); + + let right_section = Row::new() + .spacing(8) + .align_y(Alignment::Center) + .push(right_toggle); + + let content = Row::new() + .spacing(8) + .align_y(Alignment::Center) + .width(Length::Fill) + .push(left_section) + .push(center_section) + .push(right_section); Container::new(content) .width(Length::Fill) @@ -75,18 +122,76 @@ pub fn right_panel(model: &AppModel) -> Option> { return None; } - let meta = Column::new() - .spacing(4) - .push(Text::new("Metadata")) - .push(Text::new(format!( - "Current index: {:?}", - model.current_index - ))); + let mut content = Column::new().spacing(8).padding(4); - let panel = Container::new(meta) - .width(Length::Fixed(220.0)) + // Section header. + content = content.push(Text::new(fl!("metadata")).size(16).width(Length::Fill)); + + content = content.push(widget::divider::horizontal::default()); + + if let Some(meta) = &model.metadata { + // Basic information section. + content = content + .push(meta_row(fl!("file-name"), meta.basic.file_name.clone())) + .push(meta_row(fl!("format"), meta.basic.format.clone())) + .push(meta_row(fl!("resolution"), meta.basic.resolution_display())) + .push(meta_row(fl!("file-size"), meta.basic.file_size_display())) + .push(meta_row(fl!("color-type"), meta.basic.color_type.clone())); + + // EXIF section (if available). + if let Some(exif) = &meta.exif { + content = content + .push(widget::vertical_space().height(Length::Fixed(12.0))) + .push(Text::new(fl!("exif-data")).size(14)) + .push(widget::divider::horizontal::default()); + + if let Some(camera) = exif.camera_display() { + content = content.push(meta_row(fl!("camera"), camera)); + } + if let Some(date) = &exif.date_time { + content = content.push(meta_row(fl!("date-taken"), date.clone())); + } + if let Some(exp) = &exif.exposure_time { + content = content.push(meta_row(fl!("exposure"), exp.clone())); + } + if let Some(aperture) = &exif.f_number { + content = content.push(meta_row(fl!("aperture"), aperture.clone())); + } + if let Some(iso) = exif.iso { + content = content.push(meta_row(fl!("iso"), iso.to_string())); + } + if let Some(focal) = &exif.focal_length { + content = content.push(meta_row(fl!("focal-length"), focal.clone())); + } + if let Some(gps) = exif.gps_display() { + content = content.push(meta_row(fl!("gps"), gps)); + } + } + } else if model.document.is_some() { + // Document exists but metadata not yet loaded. + content = content.push(Text::new(fl!("loading-metadata"))); + } else { + // No document loaded. + content = content.push(Text::new(fl!("no-document"))); + } + + let panel = Container::new(widget::scrollable(content).height(Length::Fill)) + .width(Length::Fixed(240.0)) .height(Length::Fill) .padding(8); Some(panel.into()) } + +/// Helper to create a label-value row for metadata display. +fn meta_row(label: String, value: String) -> Element<'static, AppMessage> { + Row::new() + .spacing(8) + .push( + Text::new(format!("{}:", label)) + .size(12) + .width(Length::Fixed(80.0)), + ) + .push(Text::new(value).size(12).width(Length::Fill)) + .into() +}