Complete Clean Architecture migration
Phase 1-7: Full migration from src/app/ to Clean Architecture
BREAKING CHANGES:
- Removed src/app/ (old TEA-style implementation)
- Removed src/constant.rs (constants now local to modules)
- Removed deprecated canvas_to_image_coords functions
NEW STRUCTURE:
- src/ui/ - UI Layer (COSMIC interface)
- src/application/ - Application Layer (DocumentManager, Commands)
- src/domain/ - Domain Layer (Document types, Operations)
- src/infrastructure/ - Infrastructure Layer (Loaders, Cache, System)
FEATURES:
- DocumentManager as Single Source of Truth
- Command Pattern for all operations
- Model caching for render data (performance)
- Sync mechanism between DocumentManager and UI Model
- Wallpaper support (COSMIC, KDE, GNOME, feh)
- Thumbnail cache with disk persistence
IMPROVEMENTS:
- Warnings: 62 → 43 (-31%)
- Deprecated warnings: 2 → 0 (-100%)
- Code removed: src/app/ (~2000 lines), constant.rs, deprecated functions
- Better Locality of Reference (constants local to modules)
- Clean separation of concerns
- No circular dependencies
DOCUMENTATION:
- Updated AGENTS.md (100% migration status)
- Updated README.md (architecture section)
- Updated Workflow.md
- Added Migration-Plan.md with full completion summary
TESTS:
- All 41 tests passing
- Build successful (0 errors, 43 warnings)
- Release build verified
Migration Status: ✅ 100% Complete
This commit is contained in:
parent
f8087a3c6a
commit
fc73e4b76b
87 changed files with 9461 additions and 3324 deletions
9
src/infrastructure/cache/mod.rs
vendored
Normal file
9
src/infrastructure/cache/mod.rs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/cache/mod.rs
|
||||
//
|
||||
// Cache infrastructure: thumbnail and document caching.
|
||||
|
||||
pub mod thumbnail_cache;
|
||||
|
||||
// Re-export ThumbnailCache
|
||||
pub use thumbnail_cache::ThumbnailCache;
|
||||
149
src/infrastructure/cache/thumbnail_cache.rs
vendored
Normal file
149
src/infrastructure/cache/thumbnail_cache.rs
vendored
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/cache/thumbnail_cache.rs
|
||||
//
|
||||
// Disk cache for document thumbnails stored in ~/.cache/noctua/
|
||||
|
||||
use std::fs;
|
||||
use std::io::BufWriter;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use image::DynamicImage;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
use crate::domain::document::operations::render::create_image_handle_from_image;
|
||||
|
||||
/// Cache directory name under ~/.cache/ for thumbnail storage.
|
||||
const CACHE_DIR: &str = "noctua";
|
||||
|
||||
/// File extension for cached thumbnails.
|
||||
const THUMBNAIL_EXT: &str = "png";
|
||||
|
||||
/// Thumbnail cache manager for disk-based caching.
|
||||
pub struct ThumbnailCache;
|
||||
|
||||
impl ThumbnailCache {
|
||||
/// Load a thumbnail from disk cache.
|
||||
/// Returns None if not cached or cache is invalid.
|
||||
pub fn load(file_path: &Path, page: usize) -> Option<ImageHandle> {
|
||||
let cache_path = Self::thumbnail_path(file_path, page)?;
|
||||
|
||||
log::debug!("Cache lookup: file={}, page={}", file_path.display(), page);
|
||||
|
||||
if !cache_path.exists() {
|
||||
log::debug!(
|
||||
"Thumbnail not found in cache: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let img = image::open(&cache_path).ok()?;
|
||||
log::debug!(
|
||||
"Thumbnail loaded from cache: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
Some(create_image_handle_from_image(&img))
|
||||
}
|
||||
|
||||
/// Save a thumbnail to disk cache.
|
||||
pub fn save(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> {
|
||||
let dir = Self::ensure_cache_dir()?;
|
||||
let key = Self::cache_key(file_path, page)?;
|
||||
let cache_path = dir.join(format!("{key}.{THUMBNAIL_EXT}"));
|
||||
|
||||
log::debug!(
|
||||
"Saving thumbnail to cache: file={}, page={}, path={}",
|
||||
file_path.display(),
|
||||
page,
|
||||
cache_path.display()
|
||||
);
|
||||
|
||||
let file = fs::File::create(&cache_path).ok()?;
|
||||
let writer = BufWriter::new(file);
|
||||
|
||||
let res = image.write_to(
|
||||
&mut std::io::BufWriter::new(writer),
|
||||
image::ImageFormat::Png,
|
||||
);
|
||||
match res {
|
||||
Ok(()) => {
|
||||
log::debug!(
|
||||
"Thumbnail cached successfully: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
Some(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to cache thumbnail: file={} page={}: {}",
|
||||
file_path.display(),
|
||||
page,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cached thumbnails.
|
||||
pub fn clear_cache() -> std::io::Result<()> {
|
||||
if let Some(dir) = Self::cache_dir()
|
||||
&& dir.exists()
|
||||
{
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a thumbnail exists in cache.
|
||||
#[allow(dead_code)]
|
||||
pub fn has(file_path: &Path, page: usize) -> bool {
|
||||
Self::thumbnail_path(file_path, page).is_some_and(|p| p.exists())
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
/// Get the cache directory path (~/.cache/noctua/).
|
||||
fn cache_dir() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|p| p.join(CACHE_DIR))
|
||||
}
|
||||
|
||||
/// Ensure the cache directory exists.
|
||||
fn ensure_cache_dir() -> Option<PathBuf> {
|
||||
let dir = Self::cache_dir()?;
|
||||
fs::create_dir_all(&dir).ok()?;
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// Generate a cache key from file path, modification time, and page number.
|
||||
/// Format: sha256(path + mtime + page)
|
||||
fn cache_key(file_path: &Path, page: usize) -> Option<String> {
|
||||
let metadata = fs::metadata(file_path).ok()?;
|
||||
let mtime = metadata
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(file_path.to_string_lossy().as_bytes());
|
||||
hasher.update(mtime.to_le_bytes());
|
||||
hasher.update(page.to_le_bytes());
|
||||
|
||||
let hash = hasher.finalize();
|
||||
Some(format!("{hash:x}"))
|
||||
}
|
||||
|
||||
/// Get the full path for a cached thumbnail.
|
||||
fn thumbnail_path(file_path: &Path, page: usize) -> Option<PathBuf> {
|
||||
let dir = Self::cache_dir()?;
|
||||
let key = Self::cache_key(file_path, page)?;
|
||||
Some(dir.join(format!("{key}.{THUMBNAIL_EXT}")))
|
||||
}
|
||||
}
|
||||
189
src/infrastructure/filesystem/file_ops.rs
Normal file
189
src/infrastructure/filesystem/file_ops.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/filesystem/file_ops.rs
|
||||
//
|
||||
// File system operations for document handling.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use crate::domain::document::core::content::{DocumentContent, DocumentKind};
|
||||
|
||||
use crate::domain::document::types::raster::RasterDocument;
|
||||
#[cfg(feature = "vector")]
|
||||
use crate::domain::document::types::vector::VectorDocument;
|
||||
#[cfg(feature = "portable")]
|
||||
use crate::domain::document::types::portable::PortableDocument;
|
||||
|
||||
/// Open a document from a file path and dispatch to the correct type.
|
||||
///
|
||||
/// Raster formats are delegated to the `image` crate, which decides
|
||||
/// based on enabled codecs (e.g. default-formats).
|
||||
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(path)
|
||||
.ok_or_else(|| anyhow!("Unsupported document type: {}", path.display()))?;
|
||||
|
||||
let content = match kind {
|
||||
DocumentKind::Raster => {
|
||||
let raster = RasterDocument::open(path)?;
|
||||
DocumentContent::Raster(raster)
|
||||
}
|
||||
#[cfg(feature = "vector")]
|
||||
DocumentKind::Vector => {
|
||||
let vector = VectorDocument::open(path)?;
|
||||
DocumentContent::Vector(vector)
|
||||
}
|
||||
#[cfg(feature = "portable")]
|
||||
DocumentKind::Portable => {
|
||||
let portable = PortableDocument::open(path)?;
|
||||
DocumentContent::Portable(portable)
|
||||
}
|
||||
#[cfg(not(any(feature = "vector", feature = "portable")))]
|
||||
_ => return Err(anyhow!("No document features enabled")),
|
||||
};
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Collect all supported document files from a directory, sorted alphabetically.
|
||||
///
|
||||
/// This scans the directory and returns a list of files that are recognized as
|
||||
/// supported document types (images, PDFs, SVGs, etc.).
|
||||
pub fn collect_supported_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut entries: Vec<PathBuf> = Vec::new();
|
||||
|
||||
if let Ok(read_dir) = fs::read_dir(dir) {
|
||||
for entry in read_dir.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Only keep regular files that are recognized as supported documents.
|
||||
if path.is_file() && DocumentKind::from_path(&path).is_some() {
|
||||
entries.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort();
|
||||
entries
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEPRECATED FUNCTIONS
|
||||
// ---------------------------------------------------------------------------
|
||||
// The following functions have been replaced by DocumentManager and are
|
||||
// commented out to avoid AppModel dependencies.
|
||||
//
|
||||
// Instead of using these functions directly, use:
|
||||
// - DocumentManager::open_document() for opening files
|
||||
// - DocumentManager::next_document() / previous_document() for navigation
|
||||
// - Application commands for operations like crop, save, etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
/// Open the initial path passed on the command line.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
||||
pub fn open_initial_path(model: &mut AppModel, path: &PathBuf) {
|
||||
if path.is_dir() {
|
||||
open_from_directory(model, path);
|
||||
} else {
|
||||
open_single_file(model, path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the first supported document from the given directory.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
||||
pub fn open_from_directory(model: &mut AppModel, dir: &Path) {
|
||||
let entries = collect_supported_files(dir);
|
||||
|
||||
if entries.is_empty() {
|
||||
model.set_error(format!(
|
||||
"No supported documents found in directory: {}",
|
||||
dir.display()
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let first = entries[0].clone();
|
||||
model.folder_entries = entries;
|
||||
model.current_index = Some(0);
|
||||
|
||||
load_document_into_model(model, &first);
|
||||
}
|
||||
|
||||
/// Open a single file.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
||||
pub fn open_single_file(model: &mut AppModel, path: &Path) {
|
||||
load_document_into_model(model, path);
|
||||
|
||||
if model.document.is_some()
|
||||
&& let Some(parent) = path.parent()
|
||||
{
|
||||
refresh_folder_entries(model, parent, path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a document into the model.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager methods instead.
|
||||
fn load_document_into_model(model: &mut AppModel, path: &Path) {
|
||||
// Implementation omitted - use DocumentManager instead
|
||||
}
|
||||
|
||||
/// Refresh folder entries.
|
||||
///
|
||||
/// DEPRECATED: DocumentManager handles this automatically.
|
||||
pub fn refresh_folder_entries(model: &mut AppModel, folder: &Path, current: &Path) {
|
||||
// Implementation omitted - use DocumentManager instead
|
||||
}
|
||||
|
||||
/// Navigate to the next document.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::next_document() instead.
|
||||
pub fn navigate_next(model: &mut AppModel) {
|
||||
// Implementation omitted - use DocumentManager instead
|
||||
}
|
||||
|
||||
/// Navigate to the previous document.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::previous_document() instead.
|
||||
pub fn navigate_prev(model: &mut AppModel) {
|
||||
// Implementation omitted - use DocumentManager instead
|
||||
}
|
||||
|
||||
/// Apply crop operation.
|
||||
///
|
||||
/// DEPRECATED: Use CropDocumentCommand instead.
|
||||
pub fn apply_crop(
|
||||
crop_selection: &CropSelection,
|
||||
doc: &DocumentContent,
|
||||
current_path: &Path,
|
||||
canvas_size: cosmic::iced::Size,
|
||||
image_size: cosmic::iced::Size,
|
||||
scale: f32,
|
||||
pan_x: f32,
|
||||
pan_y: f32,
|
||||
view_mode: &ViewMode,
|
||||
) -> Result<PathBuf, String> {
|
||||
// Implementation omitted - use CropDocumentCommand instead
|
||||
Err("Deprecated function - use CropDocumentCommand".to_string())
|
||||
}
|
||||
*/
|
||||
9
src/infrastructure/filesystem/mod.rs
Normal file
9
src/infrastructure/filesystem/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/filesystem/mod.rs
|
||||
//
|
||||
// Filesystem operations: file I/O, folder scanning, and file watching.
|
||||
|
||||
pub mod file_ops;
|
||||
|
||||
// TODO: Re-implement these helpers without UI dependencies
|
||||
// pub use file_ops::{file_size, read_file_bytes};
|
||||
148
src/infrastructure/loaders/document_loader.rs
Normal file
148
src/infrastructure/loaders/document_loader.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/document_loader.rs
|
||||
//
|
||||
// Document loader trait and factory for loading documents from files.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::document::core::content::{DocumentContent, DocumentKind};
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
|
||||
use super::raster_loader::RasterLoader;
|
||||
#[cfg(feature = "vector")]
|
||||
use super::svg_loader::SvgLoader;
|
||||
#[cfg(feature = "portable")]
|
||||
use super::pdf_loader::PdfLoader;
|
||||
|
||||
/// Trait for loading documents from files.
|
||||
///
|
||||
/// Implementations handle specific document formats (raster, vector, portable).
|
||||
pub trait DocumentLoader {
|
||||
/// Load a document from a file path.
|
||||
fn load(&self, path: &Path) -> DocResult<DocumentContent>;
|
||||
|
||||
/// Check if this loader supports the given file.
|
||||
fn supports(&self, path: &Path) -> bool;
|
||||
}
|
||||
|
||||
/// Document loader factory.
|
||||
///
|
||||
/// Detects the document format and delegates to the appropriate loader.
|
||||
pub struct DocumentLoaderFactory;
|
||||
|
||||
impl DocumentLoaderFactory {
|
||||
/// Create a new document loader factory.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Load a document from a file, automatically detecting the format.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The file format is not supported
|
||||
/// - The file cannot be read
|
||||
/// - The document is malformed
|
||||
pub fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(path).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Unsupported file format: {}",
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("unknown")
|
||||
)
|
||||
})?;
|
||||
|
||||
match kind {
|
||||
DocumentKind::Raster => {
|
||||
let loader = RasterLoader;
|
||||
loader.load(path)
|
||||
}
|
||||
#[cfg(feature = "vector")]
|
||||
DocumentKind::Vector => {
|
||||
let loader = SvgLoader;
|
||||
loader.load(path)
|
||||
}
|
||||
#[cfg(feature = "portable")]
|
||||
DocumentKind::Portable => {
|
||||
let loader = PdfLoader;
|
||||
loader.load(path)
|
||||
}
|
||||
#[cfg(not(any(feature = "vector", feature = "portable")))]
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"No document loaders available (check feature flags)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the document kind from a file path.
|
||||
#[must_use]
|
||||
pub fn detect_kind(&self, path: &Path) -> Option<DocumentKind> {
|
||||
DocumentKind::from_path(path)
|
||||
}
|
||||
|
||||
/// Check if a file is supported by any loader.
|
||||
#[must_use]
|
||||
pub fn is_supported(&self, path: &Path) -> bool {
|
||||
DocumentKind::from_path(path).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DocumentLoaderFactory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_factory_creation() {
|
||||
let factory = DocumentLoaderFactory::new();
|
||||
assert!(std::ptr::eq(&factory, &factory)); // Just a dummy test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_kind() {
|
||||
let factory = DocumentLoaderFactory::new();
|
||||
|
||||
assert_eq!(
|
||||
factory.detect_kind(Path::new("test.png")),
|
||||
Some(DocumentKind::Raster)
|
||||
);
|
||||
assert_eq!(
|
||||
factory.detect_kind(Path::new("test.jpg")),
|
||||
Some(DocumentKind::Raster)
|
||||
);
|
||||
|
||||
#[cfg(feature = "vector")]
|
||||
{
|
||||
assert_eq!(
|
||||
factory.detect_kind(Path::new("test.svg")),
|
||||
Some(DocumentKind::Vector)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "portable")]
|
||||
{
|
||||
assert_eq!(
|
||||
factory.detect_kind(Path::new("test.pdf")),
|
||||
Some(DocumentKind::Portable)
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(factory.detect_kind(Path::new("test.txt")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_supported() {
|
||||
let factory = DocumentLoaderFactory::new();
|
||||
|
||||
assert!(factory.is_supported(Path::new("test.png")));
|
||||
assert!(!factory.is_supported(Path::new("test.txt")));
|
||||
}
|
||||
}
|
||||
15
src/infrastructure/loaders/mod.rs
Normal file
15
src/infrastructure/loaders/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/mod.rs
|
||||
//
|
||||
// Document loaders for various formats.
|
||||
|
||||
pub mod document_loader;
|
||||
|
||||
pub mod raster_loader;
|
||||
#[cfg(feature = "vector")]
|
||||
pub mod svg_loader;
|
||||
#[cfg(feature = "portable")]
|
||||
pub mod pdf_loader;
|
||||
|
||||
// Re-export main types
|
||||
pub use document_loader::DocumentLoaderFactory;
|
||||
50
src/infrastructure/loaders/pdf_loader.rs
Normal file
50
src/infrastructure/loaders/pdf_loader.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/pdf_loader.rs
|
||||
//
|
||||
// Loader for PDF portable documents.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::types::portable::PortableDocument;
|
||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
||||
|
||||
/// Loader for PDF portable documents.
|
||||
pub struct PdfLoader;
|
||||
|
||||
impl DocumentLoader for PdfLoader {
|
||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
||||
let document = PortableDocument::open(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load PDF document: {e}"))?;
|
||||
|
||||
Ok(DocumentContent::Portable(document))
|
||||
}
|
||||
|
||||
fn supports(&self, path: &Path) -> bool {
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
ext_str == "pdf"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_supports() {
|
||||
let loader = PdfLoader;
|
||||
|
||||
assert!(loader.supports(Path::new("test.pdf")));
|
||||
assert!(loader.supports(Path::new("test.PDF")));
|
||||
assert!(loader.supports(Path::new("document.pdf")));
|
||||
assert!(!loader.supports(Path::new("test.png")));
|
||||
assert!(!loader.supports(Path::new("test.svg")));
|
||||
assert!(!loader.supports(Path::new("test.jpg")));
|
||||
assert!(!loader.supports(Path::new("test.txt")));
|
||||
}
|
||||
}
|
||||
46
src/infrastructure/loaders/raster_loader.rs
Normal file
46
src/infrastructure/loaders/raster_loader.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/raster_loader.rs
|
||||
//
|
||||
// Loader for raster image documents (PNG, JPEG, WebP, etc.).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::types::raster::RasterDocument;
|
||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
||||
|
||||
/// Loader for raster image documents.
|
||||
pub struct RasterLoader;
|
||||
|
||||
impl DocumentLoader for RasterLoader {
|
||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
||||
let document = RasterDocument::open(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load raster document: {e}"))?;
|
||||
|
||||
Ok(DocumentContent::Raster(document))
|
||||
}
|
||||
|
||||
fn supports(&self, path: &Path) -> bool {
|
||||
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat;
|
||||
|
||||
ImageFormat::from_path(path).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_supports() {
|
||||
let loader = RasterLoader;
|
||||
|
||||
assert!(loader.supports(Path::new("test.png")));
|
||||
assert!(loader.supports(Path::new("test.jpg")));
|
||||
assert!(loader.supports(Path::new("test.jpeg")));
|
||||
assert!(loader.supports(Path::new("test.webp")));
|
||||
assert!(!loader.supports(Path::new("test.pdf")));
|
||||
assert!(!loader.supports(Path::new("test.svg")));
|
||||
}
|
||||
}
|
||||
49
src/infrastructure/loaders/svg_loader.rs
Normal file
49
src/infrastructure/loaders/svg_loader.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/svg_loader.rs
|
||||
//
|
||||
// Loader for SVG vector documents.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::types::vector::VectorDocument;
|
||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
||||
|
||||
/// Loader for SVG vector documents.
|
||||
pub struct SvgLoader;
|
||||
|
||||
impl DocumentLoader for SvgLoader {
|
||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
||||
let document = VectorDocument::open(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load SVG document: {e}"))?;
|
||||
|
||||
Ok(DocumentContent::Vector(document))
|
||||
}
|
||||
|
||||
fn supports(&self, path: &Path) -> bool {
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
ext_str == "svg" || ext_str == "svgz"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_supports() {
|
||||
let loader = SvgLoader;
|
||||
|
||||
assert!(loader.supports(Path::new("test.svg")));
|
||||
assert!(loader.supports(Path::new("test.SVG")));
|
||||
assert!(loader.supports(Path::new("test.svgz")));
|
||||
assert!(!loader.supports(Path::new("test.png")));
|
||||
assert!(!loader.supports(Path::new("test.pdf")));
|
||||
assert!(!loader.supports(Path::new("test.jpg")));
|
||||
}
|
||||
}
|
||||
13
src/infrastructure/mod.rs
Normal file
13
src/infrastructure/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/mod.rs
|
||||
//
|
||||
// Infrastructure layer: external dependencies, loaders, cache, and filesystem.
|
||||
|
||||
pub mod cache;
|
||||
pub mod filesystem;
|
||||
pub mod loaders;
|
||||
pub mod system;
|
||||
|
||||
// Re-export loader factory
|
||||
#[allow(unused_imports)]
|
||||
pub use loaders::DocumentLoaderFactory;
|
||||
9
src/infrastructure/system/mod.rs
Normal file
9
src/infrastructure/system/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/system/mod.rs
|
||||
//
|
||||
// System integration: wallpaper, desktop environment utilities.
|
||||
|
||||
pub mod wallpaper;
|
||||
|
||||
// Re-export wallpaper function
|
||||
pub use wallpaper::set_as_wallpaper;
|
||||
159
src/infrastructure/system/wallpaper.rs
Normal file
159
src/infrastructure/system/wallpaper.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/system/wallpaper.rs
|
||||
//
|
||||
// Set desktop wallpaper across different desktop environments.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Set an image as desktop wallpaper using multiple fallback methods.
|
||||
///
|
||||
/// Attempts the following methods in order:
|
||||
/// 1. COSMIC Desktop (direct config file modification)
|
||||
/// 2. wallpaper crate (KDE, XFCE, Windows, macOS)
|
||||
/// 3. gsettings (GNOME)
|
||||
/// 4. feh (tiling window managers)
|
||||
pub fn set_as_wallpaper(path: &Path) {
|
||||
// Canonicalize to absolute path.
|
||||
let abs_path = match path.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("Failed to canonicalize path {}: {}", path.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(path_str) = abs_path.to_str() else {
|
||||
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
|
||||
return;
|
||||
};
|
||||
|
||||
log::info!("Attempting to set wallpaper: {path_str}");
|
||||
|
||||
// Method 1: Try COSMIC Desktop (direct config file modification).
|
||||
if try_cosmic_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS).
|
||||
if try_wallpaper_crate(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 3: Try GNOME via gsettings.
|
||||
if try_gsettings_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 4: Try feh (common on tiling WMs like i3, sway).
|
||||
if try_feh_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
log::error!("All methods failed to set wallpaper");
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via COSMIC config file.
|
||||
fn try_cosmic_wallpaper(path_str: &str) -> bool {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all");
|
||||
if !cosmic_config.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let config_content = format!(
|
||||
r#"(
|
||||
output: "all",
|
||||
source: Path("{path_str}"),
|
||||
filter_by_theme: true,
|
||||
rotation_frequency: 300,
|
||||
filter_method: Lanczos,
|
||||
scaling_mode: Zoom,
|
||||
sampling_method: Alphanumeric,
|
||||
)"#
|
||||
);
|
||||
|
||||
match std::fs::write(&cosmic_config, config_content) {
|
||||
Ok(()) => {
|
||||
log::info!("Wallpaper set via COSMIC config");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to write COSMIC config: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via wallpaper crate.
|
||||
fn try_wallpaper_crate(path_str: &str) -> bool {
|
||||
match wallpaper::set_from_path(path_str) {
|
||||
Ok(()) => {
|
||||
log::info!("Wallpaper set via wallpaper crate");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("wallpaper crate failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via GNOME gsettings.
|
||||
fn try_gsettings_wallpaper(path_str: &str) -> bool {
|
||||
let uri = format!("file://{path_str}");
|
||||
|
||||
let output = match std::process::Command::new("gsettings")
|
||||
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("gsettings command failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"gsettings failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
log::info!("Wallpaper set via gsettings");
|
||||
|
||||
// Also set dark mode wallpaper.
|
||||
let _ = std::process::Command::new("gsettings")
|
||||
.args([
|
||||
"set",
|
||||
"org.gnome.desktop.background",
|
||||
"picture-uri-dark",
|
||||
&uri,
|
||||
])
|
||||
.output();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via feh.
|
||||
fn try_feh_wallpaper(path_str: &str) -> bool {
|
||||
let Ok(output) = std::process::Command::new("feh")
|
||||
.args(["--bg-scale", path_str])
|
||||
.output()
|
||||
else {
|
||||
log::warn!("feh not available");
|
||||
return false;
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
log::info!("Wallpaper set via feh");
|
||||
true
|
||||
} else {
|
||||
log::warn!("feh failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue