feat(ui): add header toolbar with navigation and transform buttons

- Add header bar with nav toggle, prev/next, rotate and flip buttons
- Extract header rendering to view/header.rs (MVU architecture)
- Add RotateCW, RotateCCW, FlipHorizontal, FlipVertical messages
- Add PrevDocument, NextDocument navigation messages
- Persist nav_bar_visible and context_drawer_visible in config
- Update properties panel with document info display"
This commit is contained in:
mow 2026-01-14 18:53:36 +01:00
parent b1b0999ebe
commit 7b36ff143c
8 changed files with 232 additions and 90 deletions

View file

@ -40,28 +40,27 @@ error-unsupported-format = Unsupported file format.
## Properties panel ## Properties panel
panel-properties = Properties 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-format = Format
meta-dimensions = Dimensions
meta-filesize = Size
meta-colortype = Color Type
meta-path = Path
meta-pages = Pages meta-pages = Pages
meta-current-page = Current Page meta-current-page = Current Page
## Metadata panel (extended) ## EXIF metadata
metadata = Metadata meta-camera = Camera
file-name = File meta-datetime = Date Taken
format = Format meta-exposure = Exposure
resolution = Resolution meta-aperture = Aperture
file-size = Size meta-iso = ISO
color-type = Color meta-focal = Focal Length
meta-gps = GPS Location
## EXIF data
exif-data = EXIF Data
camera = Camera
date-taken = Date
exposure = Exposure
aperture = Aperture
iso = ISO
focal-length = Focal
gps = GPS
## States ## States
loading-metadata = Loading... loading-metadata = Loading...

View file

@ -59,6 +59,8 @@ pub enum AppMessage {
// === Panels (COSMIC-managed) === // === Panels (COSMIC-managed) ===
/// Toggle a context drawer page. /// Toggle a context drawer page.
ToggleContextPage(ContextPage), ToggleContextPage(ContextPage),
/// Toggle the nav bar (left panel) visibility.
ToggleNavBar,
// === Metadata === // === Metadata ===
/// Refresh metadata from the current document. /// Refresh metadata from the current document.

View file

@ -11,10 +11,11 @@ pub mod update;
mod view; mod view;
use cosmic::app::{context_drawer, Core}; use cosmic::app::{context_drawer, Core};
use cosmic::cosmic_config::{self, CosmicConfigEntry};
use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers}; use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers};
use cosmic::iced::window; use cosmic::iced::window;
use cosmic::iced::Subscription; use cosmic::iced::{Length, Subscription};
use cosmic::widget::{button, icon, nav_bar}; use cosmic::widget::{button, horizontal_space, icon, nav_bar};
use cosmic::{Action, Element, Task}; use cosmic::{Action, Element, Task};
pub use message::AppMessage; pub use message::AppMessage;
@ -42,6 +43,8 @@ pub struct Noctua {
pub model: AppModel, pub model: AppModel,
nav: nav_bar::Model, nav: nav_bar::Model,
context_page: ContextPage, context_page: ContextPage,
config: AppConfig,
config_handler: Option<cosmic_config::Config>,
} }
impl cosmic::Application for Noctua { impl cosmic::Application for Noctua {
@ -60,8 +63,17 @@ impl cosmic::Application for Noctua {
} }
fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Action<Self::Message>>) { fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Action<Self::Message>>) {
let config = AppConfig::default(); // Load persisted config.
let mut model = AppModel::new(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. // Use CLI arguments from `flags` to open initial file or folder.
let Flags::Args(args) = flags; let Flags::Args(args) = flags;
@ -72,8 +84,9 @@ impl cosmic::Application for Noctua {
// Initialize empty nav bar (for folder/thumbnail navigation later). // Initialize empty nav bar (for folder/thumbnail navigation later).
let nav = nav_bar::Model::default(); let nav = nav_bar::Model::default();
// Context drawer hidden by default. // Apply persisted panel states.
core.window.show_context = false; core.window.show_context = config.context_drawer_visible;
core.nav_bar_set_toggled(config.nav_bar_visible);
( (
Self { Self {
@ -81,6 +94,8 @@ impl cosmic::Application for Noctua {
model, model,
nav, nav,
context_page: ContextPage::default(), context_page: ContextPage::default(),
config,
config_handler,
}, },
Task::none(), Task::none(),
) )
@ -91,57 +106,61 @@ impl cosmic::Application for Noctua {
} }
fn update(&mut self, message: Self::Message) -> Task<Action<Self::Message>> { fn update(&mut self, message: Self::Message) -> Task<Action<Self::Message>> {
// Handle panel toggle messages. match &message {
if let AppMessage::ToggleContextPage(page) = &message { // Handle nav bar toggle.
if self.context_page == *page { AppMessage::ToggleNavBar => {
self.core.window.show_context = !self.core.window.show_context; self.config.nav_bar_visible = !self.config.nav_bar_visible;
} else { self.core.nav_bar_set_toggled(self.config.nav_bar_visible);
self.context_page = *page; self.save_config();
self.core.window.show_context = true; 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); update::update(&mut self.model, message);
Task::none() Task::none()
} }
fn header_start(&self) -> Vec<Element<Self::Message>> {
view::header::header_start(&self.model)
}
fn header_end(&self) -> Vec<Element<Self::Message>> {
view::header::header_end(&self.model)
}
fn view(&self) -> Element<Self::Message> { fn view(&self) -> Element<Self::Message> {
view::view(&self.model) view::view(&self.model)
} }
fn view_window(&self, _id: window::Id) -> Element<Self::Message> {
self.view()
}
/// Header end items (right side of header bar).
fn header_end(&self) -> Vec<Element<Self::Message>> {
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<context_drawer::ContextDrawer<Self::Message>> { fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<Self::Message>> {
if !self.core.window.show_context { if !self.core.window.show_context {
return None; return None;
} }
Some(context_drawer::context_drawer( Some(context_drawer::context_drawer(
view::panels::properties_panel(&self.model), view::panels::properties_panel(&self.model),
AppMessage::ToggleContextPage(ContextPage::Properties), AppMessage::ToggleContextPage(ContextPage::Properties),
)) ))
} }
/// Nav bar model for left panel.
fn nav_model(&self) -> Option<&nav_bar::Model> { fn nav_model(&self) -> Option<&nav_bar::Model> {
Some(&self.nav) Some(&self.nav)
} }
/// Footer with zoom controls and document info.
fn footer(&self) -> Option<Element<Self::Message>> { fn footer(&self) -> Option<Element<Self::Message>> {
Some(view::footer::view(&self.model)) 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. /// Map raw key presses + modifiers into high-level application messages.
fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> { fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
use AppMessage::*; use AppMessage::*;
@ -200,10 +228,11 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
// Reset pan. // Reset pan.
Key::Character("0") => Some(PanReset), Key::Character("0") => Some(PanReset),
// Toggle properties panel with 'i' for info. // Toggle panels.
Key::Character(ch) if ch.eq_ignore_ascii_case("i") => { Key::Character(ch) if ch.eq_ignore_ascii_case("i") => {
Some(ToggleContextPage(ContextPage::Properties)) Some(ToggleContextPage(ContextPage::Properties))
} }
Key::Character(ch) if ch.eq_ignore_ascii_case("n") => Some(ToggleNavBar),
_ => None, _ => None,
} }

View file

@ -111,6 +111,10 @@ pub fn update(model: &mut AppModel, msg: AppMessage) {
// Handled in Noctua::update() directly. // Handled in Noctua::update() directly.
} }
AppMessage::ToggleNavBar => {
// Handled in Noctua::update() directly.
}
AppMessage::NoOp => { AppMessage::NoOp => {
// Intentionally do nothing. // Intentionally do nothing.
} }

58
src/app/view/header.rs Normal file
View file

@ -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<Element<AppMessage>> {
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<Element<AppMessage>> {
vec![button::icon(icon::from_name("dialog-information-symbolic"))
.on_press(AppMessage::ToggleContextPage(ContextPage::Properties))
.into()]
}

View file

@ -5,6 +5,7 @@
mod canvas; mod canvas;
pub mod footer; pub mod footer;
pub mod header;
pub mod panels; pub mod panels;
use cosmic::Element; use cosmic::Element;

View file

@ -3,71 +3,103 @@
// //
// Panel content for COSMIC context drawer. // Panel content for COSMIC context drawer.
use cosmic::widget::{column, row, text}; use cosmic::widget::{column, divider, row, text};
use cosmic::Element; use cosmic::Element;
use crate::app::document::DocumentContent;
use crate::app::{AppMessage, AppModel}; use crate::app::{AppMessage, AppModel};
use crate::fl; use crate::fl;
/// Content for the right-side properties panel (context drawer). /// Content for the right-side properties panel (context drawer).
pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> { 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. // Header.
let header = fl!("panel-properties"); content = content.push(text::title4(fl!("panel-properties")));
content = content.push(text::title4(header));
// Display document metadata if available. // Display document metadata if available.
if let Some(ref doc) = model.document { if let Some(ref doc) = model.document {
match doc { // Use the unified interface to extract metadata.
DocumentContent::Raster(raster) => { let meta = doc.extract_meta();
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();
let lbl_dim = fl!("meta-dimensions"); // --- Basic Information Section ---
let lbl_fmt = fl!("meta-format"); 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 content = content
.push(meta_row(lbl_dim, format!("{}×{}", w, h))) .push(divider::horizontal::light())
.push(meta_row(lbl_fmt, format_str)); .push(section_header(fl!("meta-section-exif")));
}
DocumentContent::Vector(vector) => {
let (w, h) = vector.dimensions();
let lbl_dim = fl!("meta-dimensions"); if let Some(camera) = exif.camera_display() {
let lbl_fmt = fl!("meta-format"); content = content.push(meta_row(fl!("meta-camera"), camera));
}
content = content if let Some(ref date) = exif.date_time {
.push(meta_row(lbl_dim, format!("{}×{}", w, h))) content = content.push(meta_row(fl!("meta-datetime"), date.clone()));
.push(meta_row(lbl_fmt, "SVG".to_string())); }
}
DocumentContent::Portable(portable) => {
let lbl_pages = fl!("meta-pages");
let lbl_current = fl!("meta-current-page");
content = content if let Some(ref exposure) = exif.exposure_time {
.push(meta_row(lbl_pages, portable.page_count.to_string())) content = content.push(meta_row(fl!("meta-exposure"), exposure.clone()));
.push(meta_row( }
lbl_current,
(portable.current_page + 1).to_string(), 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 { } else {
let no_doc = fl!("no-document"); content = content.push(text::body(fl!("no-document")));
content = content.push(text::body(no_doc));
} }
content.into() 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. /// Helper to create a key-value metadata row.
fn meta_row(label: String, value: String) -> Element<'static, AppMessage> { fn meta_row(label: String, value: String) -> Element<'static, AppMessage> {
row::with_capacity(2) row::with_capacity(2)
@ -76,3 +108,12 @@ fn meta_row(label: String, value: String) -> Element<'static, AppMessage> {
.push(text::body(value)) .push(text::body(value))
.into() .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()
}

View file

@ -1,5 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
// src/config.rs // src/config.rs
//
// Global configuration for the application with cosmic-config support.
use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry}; use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry};
use std::path::PathBuf; use std::path::PathBuf;
@ -10,6 +12,10 @@ use std::path::PathBuf;
pub struct AppConfig { pub struct AppConfig {
/// Optional default directory to open images from. /// Optional default directory to open images from.
pub default_image_dir: Option<PathBuf>, pub default_image_dir: Option<PathBuf>,
/// 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 { impl Default for AppConfig {
@ -17,6 +23,8 @@ impl Default for AppConfig {
Self { Self {
// TODO: Use xdg dir for picture // TODO: Use xdg dir for picture
default_image_dir: Some(PathBuf::from("~/Pictures")), default_image_dir: Some(PathBuf::from("~/Pictures")),
nav_bar_visible: false,
context_drawer_visible: false,
} }
} }
} }