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<DocumentMeta> 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"
This commit is contained in:
parent
6623a12632
commit
823dfe9fa2
14 changed files with 616 additions and 153 deletions
|
|
@ -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<Vec<u8>> {
|
||||
fs::read(path).ok()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub camera_model: Option<String>,
|
||||
pub date_time: Option<String>,
|
||||
pub exposure_time: Option<String>,
|
||||
pub f_number: Option<String>,
|
||||
pub iso: Option<u32>,
|
||||
pub focal_length: Option<String>,
|
||||
pub gps_latitude: Option<f64>,
|
||||
pub gps_longitude: Option<f64>,
|
||||
}
|
||||
|
||||
impl ExifMeta {
|
||||
/// Combined camera make and model for display.
|
||||
pub fn camera_display(&self) -> Option<String> {
|
||||
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<String> {
|
||||
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<ExifMeta>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<ExifMeta> {
|
||||
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<f64> {
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<DocumentContent>,
|
||||
|
||||
/// Cached metadata for the current document.
|
||||
/// Loaded lazily when the right panel is opened.
|
||||
pub metadata: Option<DocumentMeta>,
|
||||
|
||||
/// Path of the currently opened document, if any.
|
||||
pub current_path: Option<PathBuf>,
|
||||
|
||||
|
|
@ -84,6 +90,7 @@ impl AppModel {
|
|||
Self {
|
||||
config,
|
||||
document: None,
|
||||
metadata: None,
|
||||
current_path: None,
|
||||
folder_entries: Vec::new(),
|
||||
current_index: None,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Element<'_, AppMessage>> {
|
|||
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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue