diff --git a/i18n/en/noctua.ftl b/i18n/en/noctua.ftl index a7796dc..63f1c28 100644 --- a/i18n/en/noctua.ftl +++ b/i18n/en/noctua.ftl @@ -40,28 +40,27 @@ error-unsupported-format = Unsupported file format. ## Properties panel panel-properties = Properties -meta-dimensions = Dimensions +meta-section-file = File Information +meta-section-exif = Camera Information + +## Basic metadata +meta-filename = Name meta-format = Format +meta-dimensions = Dimensions +meta-filesize = Size +meta-colortype = Color Type +meta-path = Path meta-pages = Pages meta-current-page = Current Page -## Metadata panel (extended) -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 +## EXIF metadata +meta-camera = Camera +meta-datetime = Date Taken +meta-exposure = Exposure +meta-aperture = Aperture +meta-iso = ISO +meta-focal = Focal Length +meta-gps = GPS Location ## States loading-metadata = Loading... diff --git a/src/app/message.rs b/src/app/message.rs index 9b1cb95..0154b11 100644 --- a/src/app/message.rs +++ b/src/app/message.rs @@ -59,6 +59,8 @@ pub enum AppMessage { // === Panels (COSMIC-managed) === /// Toggle a context drawer page. ToggleContextPage(ContextPage), + /// Toggle the nav bar (left panel) visibility. + ToggleNavBar, // === Metadata === /// Refresh metadata from the current document. diff --git a/src/app/mod.rs b/src/app/mod.rs index b403c76..9b1a6a6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,10 +11,11 @@ pub mod update; mod view; use cosmic::app::{context_drawer, Core}; +use cosmic::cosmic_config::{self, CosmicConfigEntry}; use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers}; use cosmic::iced::window; -use cosmic::iced::Subscription; -use cosmic::widget::{button, icon, nav_bar}; +use cosmic::iced::{Length, Subscription}; +use cosmic::widget::{button, horizontal_space, icon, nav_bar}; use cosmic::{Action, Element, Task}; pub use message::AppMessage; @@ -42,6 +43,8 @@ pub struct Noctua { pub model: AppModel, nav: nav_bar::Model, context_page: ContextPage, + config: AppConfig, + config_handler: Option, } impl cosmic::Application for Noctua { @@ -60,8 +63,17 @@ impl cosmic::Application for Noctua { } fn init(mut core: Core, flags: Self::Flags) -> (Self, Task>) { - let config = AppConfig::default(); - let mut model = AppModel::new(config); + // Load persisted config. + let (config, config_handler) = + match cosmic_config::Config::new(Self::APP_ID, AppConfig::VERSION) { + Ok(handler) => { + let config = AppConfig::get_entry(&handler).unwrap_or_default(); + (config, Some(handler)) + } + Err(_) => (AppConfig::default(), None), + }; + + let mut model = AppModel::new(config.clone()); // Use CLI arguments from `flags` to open initial file or folder. let Flags::Args(args) = flags; @@ -72,8 +84,9 @@ impl cosmic::Application for Noctua { // Initialize empty nav bar (for folder/thumbnail navigation later). let nav = nav_bar::Model::default(); - // Context drawer hidden by default. - core.window.show_context = false; + // Apply persisted panel states. + core.window.show_context = config.context_drawer_visible; + core.nav_bar_set_toggled(config.nav_bar_visible); ( Self { @@ -81,6 +94,8 @@ impl cosmic::Application for Noctua { model, nav, context_page: ContextPage::default(), + config, + config_handler, }, Task::none(), ) @@ -91,57 +106,61 @@ impl cosmic::Application for Noctua { } fn update(&mut self, message: Self::Message) -> Task> { - // Handle panel toggle messages. - if let AppMessage::ToggleContextPage(page) = &message { - if self.context_page == *page { - self.core.window.show_context = !self.core.window.show_context; - } else { - self.context_page = *page; - self.core.window.show_context = true; + match &message { + // Handle nav bar toggle. + AppMessage::ToggleNavBar => { + self.config.nav_bar_visible = !self.config.nav_bar_visible; + self.core.nav_bar_set_toggled(self.config.nav_bar_visible); + self.save_config(); + return Task::none(); } - return Task::none(); + + // Handle context panel toggle. + AppMessage::ToggleContextPage(page) => { + if self.context_page == *page { + self.core.window.show_context = !self.core.window.show_context; + } else { + self.context_page = *page; + self.core.window.show_context = true; + } + self.config.context_drawer_visible = self.core.window.show_context; + self.save_config(); + return Task::none(); + } + + _ => {} } update::update(&mut self.model, message); Task::none() } + fn header_start(&self) -> Vec> { + view::header::header_start(&self.model) + } + + fn header_end(&self) -> Vec> { + view::header::header_end(&self.model) + } + fn view(&self) -> Element { view::view(&self.model) } - fn view_window(&self, _id: window::Id) -> Element { - self.view() - } - - /// Header end items (right side of header bar). - fn header_end(&self) -> Vec> { - vec![ - // Properties panel toggle button. - button::icon(icon::from_name("document-properties-symbolic")) - .on_press(AppMessage::ToggleContextPage(ContextPage::Properties)) - .into(), - ] - } - - /// Right-side context drawer (properties panel). fn context_drawer(&self) -> Option> { if !self.core.window.show_context { return None; } - Some(context_drawer::context_drawer( view::panels::properties_panel(&self.model), AppMessage::ToggleContextPage(ContextPage::Properties), )) } - /// Nav bar model for left panel. fn nav_model(&self) -> Option<&nav_bar::Model> { Some(&self.nav) } - /// Footer with zoom controls and document info. fn footer(&self) -> Option> { Some(view::footer::view(&self.model)) } @@ -151,6 +170,15 @@ impl cosmic::Application for Noctua { } } +impl Noctua { + /// Save current config to disk. + fn save_config(&self) { + if let Some(ref handler) = self.config_handler { + let _ = self.config.write_entry(handler); + } + } +} + /// Map raw key presses + modifiers into high-level application messages. fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { use AppMessage::*; @@ -200,10 +228,11 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option { // Reset pan. Key::Character("0") => Some(PanReset), - // Toggle properties panel with 'i' for info. + // Toggle panels. Key::Character(ch) if ch.eq_ignore_ascii_case("i") => { Some(ToggleContextPage(ContextPage::Properties)) } + Key::Character(ch) if ch.eq_ignore_ascii_case("n") => Some(ToggleNavBar), _ => None, } diff --git a/src/app/update.rs b/src/app/update.rs index 9e52160..8f259f9 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -111,6 +111,10 @@ pub fn update(model: &mut AppModel, msg: AppMessage) { // Handled in Noctua::update() directly. } + AppMessage::ToggleNavBar => { + // Handled in Noctua::update() directly. + } + AppMessage::NoOp => { // Intentionally do nothing. } diff --git a/src/app/view/header.rs b/src/app/view/header.rs new file mode 100644 index 0000000..4a22a60 --- /dev/null +++ b/src/app/view/header.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// src/app/view/header.rs +// +// Header bar buttons (navigation, rotation, flip). + +use cosmic::iced::Length; +use cosmic::widget::{button, horizontal_space, icon}; +use cosmic::Element; + +use crate::app::message::AppMessage; +use crate::app::model::AppModel; +use crate::app::ContextPage; + +/// Build the left side of the header bar. +pub fn header_start(model: &AppModel) -> Vec> { + let has_doc = model.document.is_some(); + + vec![ + // Nav bar toggle + button::icon(icon::from_name("view-sidebar-start-symbolic")) + .on_press(AppMessage::ToggleNavBar) + .into(), + // Spacer + horizontal_space().width(Length::Fixed(12.0)).into(), + // Navigation: previous / next + button::icon(icon::from_name("go-previous-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::PrevDocument)) + .into(), + button::icon(icon::from_name("go-next-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::NextDocument)) + .into(), + // Spacer + horizontal_space().width(Length::Fixed(12.0)).into(), + // Rotation: counter-clockwise / clockwise + button::icon(icon::from_name("object-rotate-left-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::RotateCCW)) + .into(), + button::icon(icon::from_name("object-rotate-right-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::RotateCW)) + .into(), + // Spacer + horizontal_space().width(Length::Fixed(12.0)).into(), + // Flip: horizontal / vertical + button::icon(icon::from_name("object-flip-horizontal-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::FlipHorizontal)) + .into(), + button::icon(icon::from_name("object-flip-vertical-symbolic")) + .on_press_maybe(has_doc.then_some(AppMessage::FlipVertical)) + .into(), + ] +} + +/// Build the right side of the header bar. +pub fn header_end(model: &AppModel) -> Vec> { + vec![button::icon(icon::from_name("dialog-information-symbolic")) + .on_press(AppMessage::ToggleContextPage(ContextPage::Properties)) + .into()] +} diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b2b0e59..d2cdd5b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -5,6 +5,7 @@ mod canvas; pub mod footer; +pub mod header; pub mod panels; use cosmic::Element; diff --git a/src/app/view/panels.rs b/src/app/view/panels.rs index b2f533b..e5c618a 100644 --- a/src/app/view/panels.rs +++ b/src/app/view/panels.rs @@ -3,71 +3,103 @@ // // Panel content for COSMIC context drawer. -use cosmic::widget::{column, row, text}; +use cosmic::widget::{column, divider, row, text}; use cosmic::Element; -use crate::app::document::DocumentContent; use crate::app::{AppMessage, AppModel}; use crate::fl; /// Content for the right-side properties panel (context drawer). pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> { - let mut content = column::with_capacity(6).spacing(12); + let mut content = column::with_capacity(16).spacing(8); // Header. - let header = fl!("panel-properties"); - content = content.push(text::title4(header)); + content = content.push(text::title4(fl!("panel-properties"))); // Display document metadata if available. if let Some(ref doc) = model.document { - match doc { - DocumentContent::Raster(raster) => { - let (w, h) = raster.dimensions(); - let format_str = raster - .path - .as_ref() - .and_then(|p| p.extension()) - .and_then(|e| e.to_str()) - .unwrap_or("unknown") - .to_uppercase(); + // Use the unified interface to extract metadata. + let meta = doc.extract_meta(); - let lbl_dim = fl!("meta-dimensions"); - let lbl_fmt = fl!("meta-format"); + // --- Basic Information Section --- + content = content + .push(section_header(fl!("meta-section-file"))) + .push(meta_row(fl!("meta-filename"), meta.basic.file_name.clone())) + .push(meta_row(fl!("meta-format"), meta.basic.format.clone())) + .push(meta_row( + fl!("meta-dimensions"), + meta.basic.resolution_display(), + )) + .push(meta_row( + fl!("meta-filesize"), + meta.basic.file_size_display(), + )) + .push(meta_row( + fl!("meta-colortype"), + meta.basic.color_type.clone(), + )); + // --- EXIF Section (if available) --- + if let Some(ref exif) = meta.exif { + let has_exif_data = exif.camera_display().is_some() + || exif.date_time.is_some() + || exif.exposure_time.is_some() + || exif.f_number.is_some() + || exif.iso.is_some() + || exif.focal_length.is_some() + || exif.gps_display().is_some(); + + if has_exif_data { content = content - .push(meta_row(lbl_dim, format!("{}×{}", w, h))) - .push(meta_row(lbl_fmt, format_str)); - } - DocumentContent::Vector(vector) => { - let (w, h) = vector.dimensions(); + .push(divider::horizontal::light()) + .push(section_header(fl!("meta-section-exif"))); - let lbl_dim = fl!("meta-dimensions"); - let lbl_fmt = fl!("meta-format"); + if let Some(camera) = exif.camera_display() { + content = content.push(meta_row(fl!("meta-camera"), camera)); + } - content = content - .push(meta_row(lbl_dim, format!("{}×{}", w, h))) - .push(meta_row(lbl_fmt, "SVG".to_string())); - } - DocumentContent::Portable(portable) => { - let lbl_pages = fl!("meta-pages"); - let lbl_current = fl!("meta-current-page"); + if let Some(ref date) = exif.date_time { + content = content.push(meta_row(fl!("meta-datetime"), date.clone())); + } - content = content - .push(meta_row(lbl_pages, portable.page_count.to_string())) - .push(meta_row( - lbl_current, - (portable.current_page + 1).to_string(), - )); + if let Some(ref exposure) = exif.exposure_time { + content = content.push(meta_row(fl!("meta-exposure"), exposure.clone())); + } + + if let Some(ref fnumber) = exif.f_number { + content = content.push(meta_row(fl!("meta-aperture"), fnumber.clone())); + } + + if let Some(iso) = exif.iso { + content = content.push(meta_row(fl!("meta-iso"), format!("ISO {}", iso))); + } + + if let Some(ref focal) = exif.focal_length { + content = content.push(meta_row(fl!("meta-focal"), focal.clone())); + } + + if let Some(gps) = exif.gps_display() { + content = content.push(meta_row(fl!("meta-gps"), gps)); + } } } + + // --- File Path (at the bottom, less prominent) --- + content = content + .push(divider::horizontal::light()) + .push(meta_row_small(fl!("meta-path"), meta.basic.file_path.clone())); } else { - let no_doc = fl!("no-document"); - content = content.push(text::body(no_doc)); + content = content.push(text::body(fl!("no-document"))); } content.into() } +/// Section header for grouping metadata. +fn section_header(label: String) -> Element<'static, AppMessage> { + text::body(label).into() +} + /// Helper to create a key-value metadata row. fn meta_row(label: String, value: String) -> Element<'static, AppMessage> { row::with_capacity(2) @@ -76,3 +108,12 @@ fn meta_row(label: String, value: String) -> Element<'static, AppMessage> { .push(text::body(value)) .into() } + +/// Helper for less prominent metadata (smaller text, e.g., file path). +fn meta_row_small(label: String, value: String) -> Element<'static, AppMessage> { + column::with_capacity(2) + .spacing(2) + .push(text::caption(format!("{}:", label))) + .push(text::caption(value)) + .into() +} diff --git a/src/config.rs b/src/config.rs index 6102270..0539ecc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // src/config.rs +// +// Global configuration for the application with cosmic-config support. use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry}; use std::path::PathBuf; @@ -10,6 +12,10 @@ use std::path::PathBuf; pub struct AppConfig { /// Optional default directory to open images from. pub default_image_dir: Option, + /// Whether the nav bar (left panel) is visible. + pub nav_bar_visible: bool, + /// Whether the context drawer (right panel) is visible. + pub context_drawer_visible: bool, } impl Default for AppConfig { @@ -17,6 +23,8 @@ impl Default for AppConfig { Self { // TODO: Use xdg dir for picture default_image_dir: Some(PathBuf::from("~/Pictures")), + nav_bar_visible: false, + context_drawer_visible: false, } } }