feature: PDF and PDF thumbnails and refresh UI
- Implement PDF and PDF thumbnail generation with incremental loading - Add UI refresh mechanism (tick counter + RefreshView message) - Improve fl! macro with named parameters - Clean up code organization (mod.rs: wiring, model.rs: state only)
This commit is contained in:
parent
220a886acc
commit
1182b7b55d
30 changed files with 1929 additions and 691 deletions
137
src/app/document/cache.rs
Normal file
137
src/app/document/cache.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/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 super::ImageHandle;
|
||||
use crate::constant::{CACHE_DIR, THUMBNAIL_EXT};
|
||||
|
||||
/// 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 = 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: u32) -> 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!("{:x}", hash))
|
||||
}
|
||||
|
||||
/// Get the full path for a cached thumbnail.
|
||||
fn thumbnail_path(file_path: &Path, page: u32) -> Option<PathBuf> {
|
||||
let dir = cache_dir()?;
|
||||
let key = cache_key(file_path, page)?;
|
||||
Some(dir.join(format!("{}.{}", key, THUMBNAIL_EXT)))
|
||||
}
|
||||
|
||||
/// Load a thumbnail from disk cache.
|
||||
/// Returns None if not cached or cache is invalid.
|
||||
pub fn load_thumbnail(file_path: &Path, page: u32) -> Option<ImageHandle> {
|
||||
let cache_path = 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(super::create_image_handle(&img))
|
||||
}
|
||||
|
||||
/// Save a thumbnail to disk cache.
|
||||
pub fn save_thumbnail(file_path: &Path, page: u32, image: &DynamicImage) -> Option<()> {
|
||||
let dir = ensure_cache_dir()?;
|
||||
let key = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a thumbnail exists in cache.
|
||||
pub fn has_thumbnail(file_path: &Path, page: u32) -> bool {
|
||||
thumbnail_path(file_path, page)
|
||||
.map(|p| p.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear all cached thumbnails.
|
||||
#[allow(dead_code)]
|
||||
pub fn clear_cache() -> std::io::Result<()> {
|
||||
if let Some(dir) = cache_dir() {
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -19,8 +19,8 @@ use crate::app::model::{AppModel, ViewMode};
|
|||
///
|
||||
/// Raster formats are delegated to the `image` crate, which decides
|
||||
/// based on enabled codecs (e.g. default-formats).
|
||||
pub fn open_document(path: PathBuf) -> anyhow::Result<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(&path)
|
||||
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(path)
|
||||
.ok_or_else(|| anyhow!("Unsupported document type: {:?}", path))?;
|
||||
|
||||
let content = match kind {
|
||||
|
|
@ -88,11 +88,13 @@ pub fn open_single_file(model: &mut AppModel, path: &Path) {
|
|||
|
||||
/// Load a document into the model, resetting view state.
|
||||
fn load_document_into_model(model: &mut AppModel, path: &Path) {
|
||||
match open_document(path.to_path_buf()) {
|
||||
match open_document(path) {
|
||||
Ok(doc) => {
|
||||
// Extract metadata before storing the document.
|
||||
let metadata = doc.extract_meta(path);
|
||||
|
||||
model.document = Some(doc);
|
||||
// Reset cached metadata so it gets reloaded when panel is visible.
|
||||
model.metadata = None;
|
||||
model.metadata = Some(metadata);
|
||||
model.current_path = Some(path.to_path_buf());
|
||||
model.clear_error();
|
||||
|
||||
|
|
@ -102,6 +104,7 @@ fn load_document_into_model(model: &mut AppModel, path: &Path) {
|
|||
}
|
||||
Err(err) => {
|
||||
model.document = None;
|
||||
model.metadata = None;
|
||||
model.current_path = None;
|
||||
model.set_error(err.to_string());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use image::DynamicImage;
|
|||
use exif::{In, Reader as ExifReader, Tag, Value};
|
||||
|
||||
use super::file;
|
||||
use crate::constant::{MINUTES_PER_DEGREE, SECONDS_PER_DEGREE};
|
||||
|
||||
/// Basic document metadata (always available).
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -189,7 +190,7 @@ fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<
|
|||
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
|
||||
d + m / MINUTES_PER_DEGREE + s / SECONDS_PER_DEGREE
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@
|
|||
//
|
||||
// Document module root: common enums and type erasure for document kinds.
|
||||
|
||||
pub mod cache;
|
||||
pub mod file;
|
||||
pub mod meta;
|
||||
pub mod portable;
|
||||
pub mod raster;
|
||||
pub mod transform;
|
||||
pub mod utils;
|
||||
pub mod vector;
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat;
|
||||
use image::GenericImageView;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -20,6 +20,41 @@ use self::portable::PortableDocument;
|
|||
use self::raster::RasterDocument;
|
||||
use self::vector::VectorDocument;
|
||||
|
||||
/// Trait for documents that support multiple pages (PDF, multi-page TIFF, etc.).
|
||||
pub trait MultiPage {
|
||||
/// Total number of pages in the document.
|
||||
fn page_count(&self) -> u32;
|
||||
|
||||
/// Current page index (0-based).
|
||||
fn current_page(&self) -> u32;
|
||||
|
||||
/// Navigate to a specific page.
|
||||
fn goto_page(&mut self, page: u32) -> anyhow::Result<()>;
|
||||
|
||||
/// Check if thumbnails are ready for display.
|
||||
fn thumbnails_ready(&self) -> bool;
|
||||
|
||||
/// Generate thumbnails (uses disk cache when available).
|
||||
fn generate_thumbnails(&mut self);
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
fn get_thumbnail(&self, page: u32) -> Option<ImageHandle>;
|
||||
}
|
||||
|
||||
/// Re-export the image handle type for use by submodules.
|
||||
pub type ImageHandle = cosmic::iced::widget::image::Handle;
|
||||
|
||||
/// Create an iced image handle from a DynamicImage.
|
||||
///
|
||||
/// This is the central function for converting rendered images to display handles.
|
||||
/// Used by raster, vector, and portable document types.
|
||||
pub fn create_image_handle(img: &image::DynamicImage) -> ImageHandle {
|
||||
let (w, h) = img.dimensions();
|
||||
let rgba = img.to_rgba8();
|
||||
let pixels = rgba.into_raw();
|
||||
ImageHandle::from_rgba(w, h, pixels)
|
||||
}
|
||||
|
||||
/// High-level classification of documents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DocumentKind {
|
||||
|
|
@ -79,9 +114,9 @@ impl DocumentContent {
|
|||
/// Returns a cloneable image handle for rendering.
|
||||
///
|
||||
/// This is intentionally linear: every concrete document type
|
||||
/// owns some kind of `iced_image::Handle`, and the canvas can
|
||||
/// owns some kind of `ImageHandle`, and the canvas can
|
||||
/// just call `doc.handle()` without additional branching.
|
||||
pub fn handle(&self) -> iced_image::Handle {
|
||||
pub fn handle(&self) -> ImageHandle {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.handle.clone(),
|
||||
DocumentContent::Vector(doc) => doc.handle.clone(),
|
||||
|
|
@ -101,44 +136,136 @@ impl DocumentContent {
|
|||
}
|
||||
}
|
||||
/// Extract metadata from the document.
|
||||
/// This may involve file I/O for EXIF data, so call lazily.
|
||||
pub fn extract_meta(&self) -> meta::DocumentMeta {
|
||||
/// Requires the file path for file size and EXIF extraction.
|
||||
pub fn extract_meta(&self, path: &Path) -> meta::DocumentMeta {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.extract_meta(),
|
||||
DocumentContent::Vector(doc) => doc.extract_meta(),
|
||||
DocumentContent::Portable(doc) => doc.extract_meta(),
|
||||
DocumentContent::Raster(doc) => doc.extract_meta(path),
|
||||
DocumentContent::Vector(doc) => doc.extract_meta(path),
|
||||
DocumentContent::Portable(doc) => doc.extract_meta(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate document 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.rotate_cw(),
|
||||
DocumentContent::Vector(doc) => doc.rotate_cw(),
|
||||
DocumentContent::Portable(doc) => doc.rotate_cw(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate document 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.rotate_ccw(),
|
||||
DocumentContent::Vector(doc) => doc.rotate_ccw(),
|
||||
DocumentContent::Portable(doc) => doc.rotate_ccw(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip document horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.flip_horizontal(),
|
||||
DocumentContent::Vector(doc) => doc.flip_horizontal(),
|
||||
DocumentContent::Portable(doc) => doc.flip_horizontal(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip document vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Raster(doc) => doc.flip_vertical(),
|
||||
DocumentContent::Vector(doc) => doc.flip_vertical(),
|
||||
DocumentContent::Portable(doc) => doc.flip_vertical(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this document supports multiple pages.
|
||||
pub fn is_multi_page(&self) -> bool {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.page_count() > 1,
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get page count if this is a multi-page document.
|
||||
pub fn page_count(&self) -> Option<u32> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => Some(doc.page_count()),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current page index if this is a multi-page document.
|
||||
pub fn current_page(&self) -> Option<u32> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => Some(doc.current_page()),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to a specific page if this is a multi-page document.
|
||||
pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.goto_page(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => Err(anyhow::anyhow!("Document does not support multiple pages")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
pub fn get_thumbnail(&self, page: u32) -> Option<ImageHandle> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.get_thumbnail(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if thumbnails are ready for display.
|
||||
pub fn thumbnails_ready(&self) -> bool {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.thumbnails_ready(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get number of thumbnails currently loaded.
|
||||
pub fn thumbnails_loaded(&self) -> u32 {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.thumbnails_loaded(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single thumbnail page. Returns next page to generate, or None if done.
|
||||
pub fn generate_thumbnail_page(&mut self, page: u32) -> Option<u32> {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.generate_thumbnail_page(page),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails at once (blocking).
|
||||
pub fn generate_thumbnails(&mut self) {
|
||||
match self {
|
||||
DocumentContent::Portable(doc) => doc.generate_thumbnails(),
|
||||
// TODO: RasterDocument for multi-page TIFF
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set an image file as desktop wallpaper.
|
||||
///
|
||||
/// This function attempts multiple 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)
|
||||
///
|
||||
/// The operation is performed asynchronously and logs success/failure.
|
||||
/// Delegates to `utils::set_as_wallpaper` which tries multiple methods.
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Convert to string
|
||||
let path_str = match abs_path.to_str() {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Delegate to utils with concrete string type
|
||||
utils::set_as_wallpaper(&path_str);
|
||||
utils::set_as_wallpaper(path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,317 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/portable.rs
|
||||
//
|
||||
// Portable documents (e.g. PDF) – basic model and rendering stub.
|
||||
// Portable documents (PDF) with poppler backend.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use image::{GenericImageView, DynamicImage};
|
||||
use cairo::{Context, Format, ImageSurface};
|
||||
use image::{imageops, DynamicImage, ImageReader};
|
||||
use poppler::PopplerDocument;
|
||||
|
||||
use super::{cache, ImageHandle};
|
||||
use crate::constant::{FULL_ROTATION, PDF_RENDER_SCALE, PDF_THUMBNAIL_SCALE, ROTATION_STEP};
|
||||
|
||||
/// Represents a portable document (PDF).
|
||||
pub struct PortableDocument {
|
||||
pub path: PathBuf,
|
||||
pub page_count: u32,
|
||||
pub current_page: u32,
|
||||
pub rotation: i32, // 0, 90, 180, 270; kept for future backend integration
|
||||
/// The parsed PDF document.
|
||||
document: PopplerDocument,
|
||||
/// Path to the source file (for caching).
|
||||
source_path: PathBuf,
|
||||
/// Total number of pages.
|
||||
page_count: u32,
|
||||
/// Current page index (0-based).
|
||||
current_page: u32,
|
||||
/// Rotation in degrees (0, 90, 180, 270).
|
||||
pub rotation: i16,
|
||||
/// Current rendered page as image.
|
||||
pub rendered: DynamicImage,
|
||||
pub handle: iced_image::Handle,
|
||||
// TODO: internal PDF handle from chosen backend
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Cached thumbnail handles for each page (None = not yet generated).
|
||||
thumbnail_cache: Option<Vec<ImageHandle>>,
|
||||
}
|
||||
|
||||
impl PortableDocument {
|
||||
/// Open a portable document and render the first page.
|
||||
///
|
||||
/// Currently this uses a dummy 1x1 transparent image as placeholder.
|
||||
pub fn open(path: PathBuf) -> anyhow::Result<Self> {
|
||||
// TODO: open PDF and render first page using a proper backend.
|
||||
let dummy = DynamicImage::new_rgba8(1, 1);
|
||||
let handle = Self::build_handle(&dummy);
|
||||
/// Open a PDF document and render the first page.
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let document = PopplerDocument::new_from_file(path, None)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {}", e))?;
|
||||
|
||||
let page_count = document.get_n_pages() as u32;
|
||||
if page_count == 0 {
|
||||
return Err(anyhow::anyhow!("PDF has no pages"));
|
||||
}
|
||||
|
||||
let rendered = Self::render_page(&document, 0, 0)?;
|
||||
let handle = super::create_image_handle(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
page_count: 1, // TODO: query real page count from backend
|
||||
document,
|
||||
source_path: path.to_path_buf(),
|
||||
page_count,
|
||||
current_page: 0,
|
||||
rotation: 0,
|
||||
rendered: dummy,
|
||||
rendered,
|
||||
handle,
|
||||
thumbnail_cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct an iced image handle from a DynamicImage.
|
||||
fn build_handle(img: &DynamicImage) -> iced_image::Handle {
|
||||
let (w, h) = img.dimensions();
|
||||
let rgba = img.to_rgba8();
|
||||
let pixels = rgba.into_raw();
|
||||
iced_image::Handle::from_rgba(w, h, pixels)
|
||||
/// Check if all thumbnails are ready.
|
||||
pub fn thumbnails_ready(&self) -> bool {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.map(|c| c.len() as u32 >= self.page_count)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the number of thumbnails currently loaded.
|
||||
pub fn thumbnails_loaded(&self) -> u32 {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.map(|c| c.len() as u32)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Initialize thumbnail cache (empty, ready for incremental loading).
|
||||
pub fn init_thumbnail_cache(&mut self) {
|
||||
if self.thumbnail_cache.is_none() {
|
||||
self.thumbnail_cache = Some(Vec::with_capacity(self.page_count as usize));
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single thumbnail page. Returns the next page to generate, or None if done.
|
||||
pub fn generate_thumbnail_page(&mut self, page: u32) -> Option<u32> {
|
||||
// Initialize cache if needed.
|
||||
self.init_thumbnail_cache();
|
||||
|
||||
// Check if we should generate this page.
|
||||
let should_generate = {
|
||||
let cache = self.thumbnail_cache.as_ref()?;
|
||||
page as usize >= cache.len() && page < self.page_count
|
||||
};
|
||||
|
||||
if should_generate {
|
||||
let handle = self.load_or_generate_thumbnail(page);
|
||||
if let Some(cache) = self.thumbnail_cache.as_mut() {
|
||||
cache.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Return next page if not done.
|
||||
let next = page + 1;
|
||||
if next < self.page_count {
|
||||
Some(next)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails at once (legacy, blocking).
|
||||
pub fn generate_thumbnails(&mut self) {
|
||||
if self.thumbnails_ready() {
|
||||
return;
|
||||
}
|
||||
self.init_thumbnail_cache();
|
||||
for page in 0..self.page_count {
|
||||
self.generate_thumbnail_page(page);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load thumbnail from cache or generate and cache it.
|
||||
fn load_or_generate_thumbnail(&self, page: u32) -> ImageHandle {
|
||||
if let Some(handle) = cache::load_thumbnail(&self.source_path, page) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
match Self::render_page_at_scale(&self.document, page, 0, PDF_THUMBNAIL_SCALE) {
|
||||
Ok(img) => {
|
||||
let _ = cache::save_thumbnail(&self.source_path, page, &img);
|
||||
super::create_image_handle(&img)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate thumbnail for page {}: {}", page, e);
|
||||
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a specific page from the document to an image.
|
||||
fn render_page(
|
||||
document: &PopplerDocument,
|
||||
page_index: u32,
|
||||
rotation: i16,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_SCALE)
|
||||
}
|
||||
|
||||
/// Render a specific page at a given scale.
|
||||
fn render_page_at_scale(
|
||||
document: &PopplerDocument,
|
||||
page_index: u32,
|
||||
rotation: i16,
|
||||
scale: f64,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
let page = document
|
||||
.get_page(page_index as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get page {}", page_index))?;
|
||||
|
||||
let (page_width, page_height) = page.get_size();
|
||||
|
||||
let (width, height) = if rotation == 90 || rotation == 270 {
|
||||
(page_height, page_width)
|
||||
} else {
|
||||
(page_width, page_height)
|
||||
};
|
||||
|
||||
let scaled_width = (width * scale) as i32;
|
||||
let scaled_height = (height * scale) as i32;
|
||||
|
||||
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {}", e))?;
|
||||
|
||||
let context = Context::new(&surface)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {}", e))?;
|
||||
|
||||
// Fill with white background.
|
||||
context.set_source_rgb(1.0, 1.0, 1.0);
|
||||
let _ = context.paint();
|
||||
|
||||
context.scale(scale, scale);
|
||||
|
||||
if rotation != 0 {
|
||||
let center_x = width / 2.0;
|
||||
let center_y = height / 2.0;
|
||||
context.translate(center_x, center_y);
|
||||
context.rotate(f64::from(rotation) * std::f64::consts::PI / 180.0);
|
||||
context.translate(-page_width / 2.0, -page_height / 2.0);
|
||||
}
|
||||
|
||||
page.render(&context);
|
||||
|
||||
drop(context);
|
||||
surface.flush();
|
||||
|
||||
let mut png_data: Vec<u8> = Vec::new();
|
||||
surface
|
||||
.write_to_png(&mut png_data)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write PNG: {}", e))?;
|
||||
|
||||
let image = ImageReader::new(Cursor::new(png_data))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {}", e))?
|
||||
.decode()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {}", e))?;
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
/// Re-render the current page.
|
||||
fn rerender(&mut self) {
|
||||
match Self::render_page(&self.document, self.current_page, self.rotation) {
|
||||
Ok(rendered) => {
|
||||
self.rendered = rendered;
|
||||
self.refresh_handle();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to render PDF page: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the handle after mutating `rendered`.
|
||||
pub fn refresh_handle(&mut self) {
|
||||
self.handle = Self::build_handle(&self.rendered);
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
}
|
||||
|
||||
/// Returns the dimensions of the currently rendered page.
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.rendered.dimensions()
|
||||
(self.rendered.width(), self.rendered.height())
|
||||
}
|
||||
|
||||
/// Re-render the current page with the current rotation.
|
||||
pub fn rerender_page(&mut self) {
|
||||
// TODO: use PDF backend and self.rotation / self.current_page
|
||||
// self.rendered = render_page_to_dynamic(...);
|
||||
// self.refresh_handle();
|
||||
/// Navigate to a specific page.
|
||||
pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> {
|
||||
if page >= self.page_count {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Page {} out of range (0-{})",
|
||||
page,
|
||||
self.page_count - 1
|
||||
));
|
||||
}
|
||||
self.current_page = page;
|
||||
self.rerender();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate to the next page.
|
||||
pub fn next_page(&mut self) -> bool {
|
||||
if self.current_page + 1 < self.page_count {
|
||||
self.current_page += 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the previous page.
|
||||
pub fn prev_page(&mut self) -> bool {
|
||||
if self.current_page > 0 {
|
||||
self.current_page -= 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.rotation = (self.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.rotation = (self.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.rendered));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.rendered));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Extract metadata for this portable document.
|
||||
pub fn extract_meta(&self) -> super::meta::DocumentMeta {
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
let (width, height) = self.dimensions();
|
||||
super::meta::build_portable_meta(path, width, height, self.page_count)
|
||||
}
|
||||
|
||||
super::meta::build_portable_meta(&self.path, width, height, self.page_count)
|
||||
/// Get total page count.
|
||||
pub fn page_count(&self) -> u32 {
|
||||
self.page_count
|
||||
}
|
||||
|
||||
/// Get current page index (0-based).
|
||||
pub fn current_page(&self) -> u32 {
|
||||
self.current_page
|
||||
}
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
/// Returns None if thumbnails not yet generated.
|
||||
pub fn get_thumbnail(&self, page: u32) -> Option<ImageHandle> {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.get(page as usize).cloned())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,71 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/raster.rs
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use image::{GenericImageView, DynamicImage, ImageReader};
|
||||
use image::{imageops, DynamicImage, GenericImageView, ImageReader};
|
||||
|
||||
use super::ImageHandle;
|
||||
|
||||
/// Represents a raster image document (PNG, JPEG, WebP, ...).
|
||||
pub struct RasterDocument {
|
||||
pub path: Option<PathBuf>,
|
||||
pub image: DynamicImage,
|
||||
pub handle: iced_image::Handle,
|
||||
/// The decoded image document.
|
||||
document: DynamicImage,
|
||||
/// Cached handle for rendering.
|
||||
pub handle: ImageHandle,
|
||||
}
|
||||
|
||||
impl RasterDocument {
|
||||
/// Load a raster document from disk.
|
||||
pub fn open(path: PathBuf) -> image::ImageResult<Self> {
|
||||
let img = ImageReader::open(&path)?.decode()?;
|
||||
let handle = Self::build_handle(&img);
|
||||
pub fn open(path: &Path) -> image::ImageResult<Self> {
|
||||
let document = ImageReader::open(path)?.decode()?;
|
||||
let handle = super::create_image_handle(&document);
|
||||
|
||||
Ok(Self {
|
||||
path: Some(path),
|
||||
image: img,
|
||||
handle,
|
||||
})
|
||||
Ok(Self { document, handle })
|
||||
}
|
||||
|
||||
/// Construct a handle from a DynamicImage.
|
||||
fn build_handle(img: &DynamicImage) -> iced_image::Handle {
|
||||
// Get image dimensions.
|
||||
let (w, h) = img.dimensions();
|
||||
|
||||
// Convert to RGBA8 buffer and extract raw bytes.
|
||||
let rgba = img.to_rgba8();
|
||||
let pixels = rgba.into_raw(); // Vec<u8>
|
||||
|
||||
// Build an iced image handle from raw RGBA pixels.
|
||||
iced_image::Handle::from_rgba(w, h, pixels)
|
||||
}
|
||||
|
||||
/// Rebuild the handle after mutating `image`.
|
||||
/// Rebuild the handle after mutating `document`.
|
||||
pub fn refresh_handle(&mut self) {
|
||||
self.handle = Self::build_handle(&self.image);
|
||||
self.handle = super::create_image_handle(&self.document);
|
||||
}
|
||||
|
||||
/// Returns the native pixel dimensions (width, height).
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.image.dimensions()
|
||||
self.document.dimensions()
|
||||
}
|
||||
|
||||
/// Save the current image back to disk (overwrite).
|
||||
pub fn save(&self) -> image::ImageResult<()> {
|
||||
if let Some(path) = &self.path {
|
||||
self.image.save(path)
|
||||
} else {
|
||||
// Cant imagine that it happen but caller should handle missing path case.
|
||||
Err(image::ImageError::Parameter(
|
||||
image::error::ParameterError::from_kind(image::error::ParameterErrorKind::Generic(
|
||||
"RasterDocument does not have a path".into(),
|
||||
)),
|
||||
))
|
||||
}
|
||||
/// Save the current document to disk.
|
||||
pub fn save(&self, path: &Path) -> image::ImageResult<()> {
|
||||
self.document.save(path)
|
||||
}
|
||||
|
||||
/// 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(""));
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
let (width, height) = self.dimensions();
|
||||
super::meta::build_raster_meta(path, &self.document, width, height)
|
||||
}
|
||||
|
||||
super::meta::build_raster_meta(path, &self.image, width, height)
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate90(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::rotate270(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.document = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.document));
|
||||
self.refresh_handle();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/transform.rs
|
||||
//
|
||||
// High-level document transformations (rotate, flip, etc.).
|
||||
|
||||
use image::{imageops, DynamicImage};
|
||||
|
||||
use super::portable::PortableDocument;
|
||||
use super::raster::RasterDocument;
|
||||
use super::vector::VectorDocument;
|
||||
use super::DocumentContent;
|
||||
|
||||
/// Rotate current document 90 degrees clockwise.
|
||||
pub fn rotate_cw(doc: &mut DocumentContent) {
|
||||
match doc {
|
||||
DocumentContent::Raster(raster) => rotate_cw_raster(raster),
|
||||
DocumentContent::Vector(vector) => rotate_cw_vector(vector),
|
||||
DocumentContent::Portable(portable) => rotate_cw_portable(portable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate current document 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(doc: &mut DocumentContent) {
|
||||
match doc {
|
||||
DocumentContent::Raster(raster) => rotate_ccw_raster(raster),
|
||||
DocumentContent::Vector(vector) => rotate_ccw_vector(vector),
|
||||
DocumentContent::Portable(portable) => rotate_ccw_portable(portable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip current document horizontally.
|
||||
pub fn flip_horizontal(doc: &mut DocumentContent) {
|
||||
match doc {
|
||||
DocumentContent::Raster(raster) => flip_horizontal_raster(raster),
|
||||
DocumentContent::Vector(vector) => flip_horizontal_vector(vector),
|
||||
DocumentContent::Portable(portable) => flip_horizontal_portable(portable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip current document vertically.
|
||||
pub fn flip_vertical(doc: &mut DocumentContent) {
|
||||
match doc {
|
||||
DocumentContent::Raster(raster) => flip_vertical_raster(raster),
|
||||
DocumentContent::Vector(vector) => flip_vertical_vector(vector),
|
||||
DocumentContent::Portable(portable) => flip_vertical_portable(portable),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Raster implementations ---------------------------------------------------
|
||||
|
||||
fn rotate_cw_raster(doc: &mut RasterDocument) {
|
||||
doc.image = DynamicImage::ImageRgba8(imageops::rotate90(&doc.image));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn rotate_ccw_raster(doc: &mut RasterDocument) {
|
||||
doc.image = DynamicImage::ImageRgba8(imageops::rotate270(&doc.image));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip_horizontal_raster(doc: &mut RasterDocument) {
|
||||
doc.image = DynamicImage::ImageRgba8(imageops::flip_horizontal(&doc.image));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip_vertical_raster(doc: &mut RasterDocument) {
|
||||
doc.image = DynamicImage::ImageRgba8(imageops::flip_vertical(&doc.image));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
// --- Portable implementations (operate on rendered image) ---------------------
|
||||
|
||||
fn rotate_cw_portable(doc: &mut PortableDocument) {
|
||||
// Keep rotation in sync for a future real PDF backend.
|
||||
doc.rotation = (doc.rotation + 90).rem_euclid(360);
|
||||
doc.rendered = DynamicImage::ImageRgba8(imageops::rotate90(&doc.rendered));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn rotate_ccw_portable(doc: &mut PortableDocument) {
|
||||
doc.rotation = (doc.rotation - 90).rem_euclid(360);
|
||||
doc.rendered = DynamicImage::ImageRgba8(imageops::rotate270(&doc.rendered));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip_horizontal_portable(doc: &mut PortableDocument) {
|
||||
doc.rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&doc.rendered));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
fn flip_vertical_portable(doc: &mut PortableDocument) {
|
||||
doc.rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&doc.rendered));
|
||||
doc.refresh_handle();
|
||||
}
|
||||
|
||||
// --- Vector implementations (view-transform only, for now) --------------------
|
||||
|
||||
fn rotate_cw_vector(_doc: &mut VectorDocument) {
|
||||
// TODO: either update a rotation property or re-rasterize with rotation.
|
||||
}
|
||||
|
||||
fn rotate_ccw_vector(_doc: &mut VectorDocument) {
|
||||
// TODO: either update a rotation property or re-rasterize with rotation.
|
||||
}
|
||||
|
||||
fn flip_horizontal_vector(_doc: &mut VectorDocument) {
|
||||
// TODO: apply horizontal flip to SVG or adjust view transform.
|
||||
}
|
||||
|
||||
fn flip_vertical_vector(_doc: &mut VectorDocument) {
|
||||
// TODO: apply vertical flip to SVG or adjust view transform.
|
||||
}
|
||||
|
|
@ -3,19 +3,71 @@
|
|||
//
|
||||
// Utility functions for document operations.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Set an image as desktop wallpaper using multiple fallback methods.
|
||||
///
|
||||
/// Expects an absolute path as string.
|
||||
pub fn set_as_wallpaper(path_str: &str) {
|
||||
/// 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 path_str = match abs_path.to_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
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 let Some(home) = dirs::home_dir() {
|
||||
let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all");
|
||||
// Method 1: Try COSMIC Desktop (direct config file modification).
|
||||
if try_cosmic_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
if cosmic_config.exists() {
|
||||
let config_content = format!(
|
||||
r#"(
|
||||
// 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("{}"),
|
||||
filter_by_theme: true,
|
||||
|
|
@ -24,86 +76,91 @@ pub fn set_as_wallpaper(path_str: &str) {
|
|||
scaling_mode: Zoom,
|
||||
sampling_method: Alphanumeric,
|
||||
)"#,
|
||||
path_str
|
||||
);
|
||||
path_str
|
||||
);
|
||||
|
||||
match std::fs::write(&cosmic_config, config_content) {
|
||||
Ok(_) => {
|
||||
log::info!("✓ Wallpaper set via COSMIC config file");
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to write COSMIC config: {}", e);
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS)
|
||||
/// 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 successfully via wallpaper crate");
|
||||
return;
|
||||
log::info!("Wallpaper set via wallpaper crate");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("wallpaper crate failed: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Try GNOME via gsettings
|
||||
/// Try setting wallpaper via GNOME gsettings.
|
||||
fn try_gsettings_wallpaper(path_str: &str) -> bool {
|
||||
let uri = format!("file://{}", path_str);
|
||||
log::info!("Trying gsettings with URI: {}", uri);
|
||||
|
||||
match std::process::Command::new("gsettings")
|
||||
.args(&[
|
||||
"set",
|
||||
"org.gnome.desktop.background",
|
||||
"picture-uri",
|
||||
&uri,
|
||||
])
|
||||
let output = match std::process::Command::new("gsettings")
|
||||
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("✓ Wallpaper set via gsettings (light mode)");
|
||||
|
||||
// Also set dark mode wallpaper
|
||||
let _ = std::process::Command::new("gsettings")
|
||||
.args(&[
|
||||
"set",
|
||||
"org.gnome.desktop.background",
|
||||
"picture-uri-dark",
|
||||
&uri,
|
||||
])
|
||||
.output();
|
||||
return;
|
||||
}
|
||||
Ok(output) => {
|
||||
log::warn!(
|
||||
"gsettings failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Method 4: Try feh (common on tiling WMs like i3, sway)
|
||||
match std::process::Command::new("feh")
|
||||
.args(&["--bg-scale", path_str])
|
||||
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 output = match std::process::Command::new("feh")
|
||||
.args(["--bg-scale", path_str])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
log::info!("✓ Wallpaper set via feh");
|
||||
return;
|
||||
}
|
||||
Ok(_) => {
|
||||
log::warn!("feh failed");
|
||||
}
|
||||
Ok(o) => o,
|
||||
Err(_) => {
|
||||
log::warn!("feh not available");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
log::error!("✗ All methods failed to set wallpaper");
|
||||
if output.status.success() {
|
||||
log::info!("Wallpaper set via feh");
|
||||
true
|
||||
} else {
|
||||
log::warn!("feh failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,33 +3,76 @@
|
|||
//
|
||||
// Vector documents (SVG, etc.).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use image::{imageops, DynamicImage, RgbaImage};
|
||||
use resvg::tiny_skia::{self, Pixmap};
|
||||
use resvg::usvg::{Options, Tree};
|
||||
|
||||
use super::ImageHandle;
|
||||
use crate::constant::{FULL_ROTATION, MIN_PIXMAP_SIZE, ROTATION_STEP};
|
||||
|
||||
/// Accumulated transformations for a vector document.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct VectorTransform {
|
||||
/// Rotation in degrees (0, 90, 180, 270).
|
||||
pub rotation: i16,
|
||||
/// Horizontal flip.
|
||||
pub flip_h: bool,
|
||||
/// Vertical flip.
|
||||
pub flip_v: bool,
|
||||
}
|
||||
|
||||
/// Represents a vector document such as SVG.
|
||||
/// For now this only stores the raw data and a rasterized handle.
|
||||
pub struct VectorDocument {
|
||||
pub path: PathBuf,
|
||||
pub raw_data: String,
|
||||
pub handle: iced_image::Handle,
|
||||
/// Cached dimensions of the rasterized representation.
|
||||
/// Parsed SVG document for re-rendering at different scales.
|
||||
document: Tree,
|
||||
/// Native width of the SVG (from viewBox or width attribute).
|
||||
native_width: u32,
|
||||
/// Native height of the SVG (from viewBox or height attribute).
|
||||
native_height: u32,
|
||||
/// Current render scale (1.0 = native size).
|
||||
current_scale: f32,
|
||||
/// Accumulated transformations.
|
||||
transform: VectorTransform,
|
||||
/// Rasterized image at the current scale.
|
||||
pub rendered: DynamicImage,
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Current rendered width.
|
||||
pub width: u32,
|
||||
/// Current rendered height.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl VectorDocument {
|
||||
pub fn open(path: PathBuf) -> anyhow::Result<Self> {
|
||||
let raw_data = std::fs::read_to_string(&path)?;
|
||||
/// Load a vector document from disk.
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let raw_data = std::fs::read_to_string(path)?;
|
||||
|
||||
// TODO: proper SVG parsing and rendering.
|
||||
// For now, use a placeholder size based on a typical default.
|
||||
let (width, height) = (800, 600);
|
||||
let handle = iced_image::Handle::from_rgba(1, 1, vec![0, 0, 0, 0]);
|
||||
// Parse SVG with default options.
|
||||
let options = Options::default();
|
||||
let document = Tree::from_str(&raw_data, &options)?;
|
||||
|
||||
// Get native size from the parsed document.
|
||||
let size = document.size();
|
||||
let native_width = size.width().ceil() as u32;
|
||||
let native_height = size.height().ceil() as u32;
|
||||
|
||||
let transform = VectorTransform::default();
|
||||
|
||||
// Render at native scale (1.0).
|
||||
let (rendered, width, height) =
|
||||
render_document(&document, native_width, native_height, 1.0, &transform)?;
|
||||
let handle = super::create_image_handle(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
raw_data,
|
||||
document,
|
||||
native_width,
|
||||
native_height,
|
||||
current_scale: 1.0,
|
||||
transform,
|
||||
rendered,
|
||||
handle,
|
||||
width,
|
||||
height,
|
||||
|
|
@ -41,14 +84,148 @@ impl VectorDocument {
|
|||
(self.width, self.height)
|
||||
}
|
||||
|
||||
pub fn refresh_handle(&mut self) {
|
||||
// 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();
|
||||
/// Re-render the SVG at a new scale, preserving transformations.
|
||||
/// Returns true if re-rendering occurred.
|
||||
pub fn render_at_scale(&mut self, scale: f32) -> bool {
|
||||
// Skip if scale hasn't changed
|
||||
if (self.current_scale - scale).abs() < f32::EPSILON {
|
||||
return false;
|
||||
}
|
||||
|
||||
super::meta::build_vector_meta(&self.path, width, height)
|
||||
match render_document(
|
||||
&self.document,
|
||||
self.native_width,
|
||||
self.native_height,
|
||||
scale,
|
||||
&self.transform,
|
||||
) {
|
||||
Ok((rendered, width, height)) => {
|
||||
self.current_scale = scale;
|
||||
self.rendered = rendered;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to re-render SVG at scale {}: {}", scale, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.transform.rotation =
|
||||
(self.transform.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.transform.rotation =
|
||||
(self.transform.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.transform.flip_h = !self.transform.flip_h;
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.transform.flip_v = !self.transform.flip_v;
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Re-render with current scale and transform.
|
||||
fn rerender(&mut self) {
|
||||
if let Ok((rendered, width, height)) = render_document(
|
||||
&self.document,
|
||||
self.native_width,
|
||||
self.native_height,
|
||||
self.current_scale,
|
||||
&self.transform,
|
||||
) {
|
||||
self.rendered = rendered;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract metadata for this vector document.
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
// Report native dimensions in metadata.
|
||||
super::meta::build_vector_meta(path, self.native_width, self.native_height)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the SVG document at a given scale with transformations.
|
||||
fn render_document(
|
||||
document: &Tree,
|
||||
native_width: u32,
|
||||
native_height: u32,
|
||||
scale: f32,
|
||||
transform: &VectorTransform,
|
||||
) -> anyhow::Result<(DynamicImage, u32, u32)> {
|
||||
let width = (((native_width as f32) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
||||
let height = (((native_height as f32) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
||||
|
||||
let mut pixmap =
|
||||
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;
|
||||
|
||||
let ts = tiny_skia::Transform::from_scale(scale, scale);
|
||||
resvg::render(document, ts, &mut pixmap.as_mut());
|
||||
|
||||
let mut image = pixmap_to_dynamic_image(&pixmap);
|
||||
|
||||
// Apply flip transformations
|
||||
if transform.flip_h {
|
||||
image = DynamicImage::ImageRgba8(imageops::flip_horizontal(&image));
|
||||
}
|
||||
if transform.flip_v {
|
||||
image = DynamicImage::ImageRgba8(imageops::flip_vertical(&image));
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
image = match transform.rotation {
|
||||
90 => DynamicImage::ImageRgba8(imageops::rotate90(&image)),
|
||||
180 => DynamicImage::ImageRgba8(imageops::rotate180(&image)),
|
||||
270 => DynamicImage::ImageRgba8(imageops::rotate270(&image)),
|
||||
_ => image,
|
||||
};
|
||||
|
||||
let final_width = image.width();
|
||||
let final_height = image.height();
|
||||
|
||||
Ok((image, final_width, final_height))
|
||||
}
|
||||
|
||||
/// Convert a tiny_skia Pixmap to a DynamicImage.
|
||||
fn pixmap_to_dynamic_image(pixmap: &Pixmap) -> DynamicImage {
|
||||
let width = pixmap.width();
|
||||
let height = pixmap.height();
|
||||
|
||||
// tiny_skia uses premultiplied alpha, we need to unpremultiply for image crate
|
||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
||||
for pixel in pixmap.pixels() {
|
||||
let a = pixel.alpha();
|
||||
if a == 0 {
|
||||
pixels.extend_from_slice(&[0, 0, 0, 0]);
|
||||
} else {
|
||||
// Unpremultiply: color = premultiplied_color * 255 / alpha
|
||||
let r = (pixel.red() as u16 * 255 / a as u16) as u8;
|
||||
let g = (pixel.green() as u16 * 255 / a as u16) as u8;
|
||||
let b = (pixel.blue() as u16 * 255 / a as u16) as u8;
|
||||
pixels.extend_from_slice(&[r, g, b, a]);
|
||||
}
|
||||
}
|
||||
|
||||
let rgba_image = RgbaImage::from_raw(width, height, pixels)
|
||||
.expect("Failed to create RgbaImage from pixmap data");
|
||||
|
||||
DynamicImage::ImageRgba8(rgba_image)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue