2026-01-07 20:42:28 +01:00
|
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
2026-01-07 20:22:49 +01:00
|
|
|
// src/app/mod.rs
|
|
|
|
|
//
|
|
|
|
|
// Application module root, re-exports, and COSMIC application wiring.
|
|
|
|
|
|
|
|
|
|
pub mod document;
|
|
|
|
|
pub mod message;
|
|
|
|
|
pub mod model;
|
|
|
|
|
pub mod update;
|
|
|
|
|
|
|
|
|
|
mod view;
|
|
|
|
|
|
2026-01-14 17:16:25 +01:00
|
|
|
use cosmic::app::{context_drawer, Core};
|
2026-01-08 12:18:13 +01:00
|
|
|
use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers};
|
2026-01-07 20:22:49 +01:00
|
|
|
use cosmic::iced::window;
|
|
|
|
|
use cosmic::iced::Subscription;
|
2026-01-14 17:16:25 +01:00
|
|
|
use cosmic::widget::{button, icon, nav_bar};
|
2026-01-07 20:22:49 +01:00
|
|
|
use cosmic::{Action, Element, Task};
|
|
|
|
|
|
|
|
|
|
pub use message::AppMessage;
|
|
|
|
|
pub use model::AppModel;
|
|
|
|
|
|
|
|
|
|
use crate::config::AppConfig;
|
|
|
|
|
use crate::Args;
|
|
|
|
|
|
|
|
|
|
/// Flags passed from `main` into the application.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub enum Flags {
|
|
|
|
|
Args(Args),
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 17:16:25 +01:00
|
|
|
/// Context page displayed in right drawer.
|
|
|
|
|
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
pub enum ContextPage {
|
|
|
|
|
#[default]
|
|
|
|
|
Properties,
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 20:22:49 +01:00
|
|
|
/// Main application type.
|
|
|
|
|
pub struct Noctua {
|
|
|
|
|
core: Core,
|
|
|
|
|
pub model: AppModel,
|
2026-01-14 17:16:25 +01:00
|
|
|
nav: nav_bar::Model,
|
|
|
|
|
context_page: ContextPage,
|
2026-01-07 20:22:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl cosmic::Application for Noctua {
|
|
|
|
|
type Executor = cosmic::SingleThreadExecutor;
|
|
|
|
|
type Flags = Flags;
|
|
|
|
|
type Message = AppMessage;
|
|
|
|
|
|
|
|
|
|
const APP_ID: &'static str = "org.codeberg.wfx.Noctua";
|
|
|
|
|
|
|
|
|
|
fn core(&self) -> &Core {
|
|
|
|
|
&self.core
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn core_mut(&mut self) -> &mut Core {
|
|
|
|
|
&mut self.core
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 17:16:25 +01:00
|
|
|
fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Action<Self::Message>>) {
|
2026-01-07 20:22:49 +01:00
|
|
|
let config = AppConfig::default();
|
|
|
|
|
let mut model = AppModel::new(config);
|
|
|
|
|
|
|
|
|
|
// Use CLI arguments from `flags` to open initial file or folder.
|
|
|
|
|
let Flags::Args(args) = flags;
|
|
|
|
|
if let Some(path) = args.file {
|
2026-01-08 12:18:13 +01:00
|
|
|
document::file::open_initial_path(&mut model, path);
|
2026-01-07 20:22:49 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-14 17:16:25 +01:00
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
Self {
|
|
|
|
|
core,
|
|
|
|
|
model,
|
|
|
|
|
nav,
|
|
|
|
|
context_page: ContextPage::default(),
|
|
|
|
|
},
|
|
|
|
|
Task::none(),
|
|
|
|
|
)
|
2026-01-07 20:22:49 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn on_close_requested(&self, _id: window::Id) -> Option<Self::Message> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn update(&mut self, message: Self::Message) -> Task<Action<Self::Message>> {
|
2026-01-14 17:16:25 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
return Task::none();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 20:22:49 +01:00
|
|
|
update::update(&mut self.model, message);
|
|
|
|
|
Task::none()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn view(&self) -> Element<Self::Message> {
|
|
|
|
|
view::view(&self.model)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn view_window(&self, _id: window::Id) -> Element<Self::Message> {
|
|
|
|
|
self.view()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 17:16:25 +01:00
|
|
|
/// 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>> {
|
|
|
|
|
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<Element<Self::Message>> {
|
|
|
|
|
Some(view::footer::view(&self.model))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 20:22:49 +01:00
|
|
|
fn subscription(&self) -> Subscription<Self::Message> {
|
|
|
|
|
keyboard::on_key_press(handle_key_press)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Map raw key presses + modifiers into high-level application messages.
|
|
|
|
|
fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
|
|
|
|
use AppMessage::*;
|
|
|
|
|
|
|
|
|
|
// Handle Ctrl + arrow keys for panning.
|
|
|
|
|
if modifiers.control() && !modifiers.shift() && !modifiers.alt() && !modifiers.logo() {
|
|
|
|
|
return match key.as_ref() {
|
|
|
|
|
Key::Named(Named::ArrowLeft) => Some(PanLeft),
|
|
|
|
|
Key::Named(Named::ArrowRight) => Some(PanRight),
|
|
|
|
|
Key::Named(Named::ArrowUp) => Some(PanUp),
|
|
|
|
|
Key::Named(Named::ArrowDown) => Some(PanDown),
|
|
|
|
|
_ => None,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 12:18:13 +01:00
|
|
|
// Ignore key presses when command-style modifiers are pressed.
|
2026-01-07 20:22:49 +01:00
|
|
|
if modifiers.command() || modifiers.alt() || modifiers.logo() || modifiers.control() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match key.as_ref() {
|
|
|
|
|
// Navigation with arrow keys (no modifiers).
|
|
|
|
|
Key::Named(Named::ArrowRight) => Some(NextDocument),
|
|
|
|
|
Key::Named(Named::ArrowLeft) => Some(PrevDocument),
|
|
|
|
|
|
2026-01-08 12:18:13 +01:00
|
|
|
// Transformations.
|
2026-01-07 20:22:49 +01:00
|
|
|
Key::Character(ch) if ch.eq_ignore_ascii_case("h") => Some(FlipHorizontal),
|
|
|
|
|
Key::Character(ch) if ch.eq_ignore_ascii_case("v") => Some(FlipVertical),
|
|
|
|
|
Key::Character(ch) if ch.eq_ignore_ascii_case("r") => {
|
|
|
|
|
if modifiers.shift() {
|
|
|
|
|
Some(RotateCCW)
|
|
|
|
|
} else {
|
|
|
|
|
Some(RotateCW)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 12:18:13 +01:00
|
|
|
// Zoom.
|
2026-01-07 20:22:49 +01:00
|
|
|
Key::Character("+") | Key::Character("=") => Some(ZoomIn),
|
|
|
|
|
Key::Character("-") => Some(ZoomOut),
|
|
|
|
|
Key::Character("1") => Some(ZoomReset),
|
|
|
|
|
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit),
|
|
|
|
|
|
2026-01-08 12:18:13 +01:00
|
|
|
// Tool modes.
|
2026-01-07 20:22:49 +01:00
|
|
|
Key::Character(ch) if ch.eq_ignore_ascii_case("c") => Some(ToggleCropMode),
|
|
|
|
|
Key::Character(ch) if ch.eq_ignore_ascii_case("s") => Some(ToggleScaleMode),
|
|
|
|
|
|
2026-01-08 12:18:13 +01:00
|
|
|
// Reset pan.
|
2026-01-07 20:22:49 +01:00
|
|
|
Key::Character("0") => Some(PanReset),
|
|
|
|
|
|
2026-01-14 17:16:25 +01:00
|
|
|
// Toggle properties panel with 'i' for info.
|
|
|
|
|
Key::Character(ch) if ch.eq_ignore_ascii_case("i") => {
|
|
|
|
|
Some(ToggleContextPage(ContextPage::Properties))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 20:22:49 +01:00
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|