diff --git a/Cargo.lock b/Cargo.lock index eaa8dee..dd3fdfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,6 +1081,7 @@ dependencies = [ "once_cell", "regex", "slotmap", + "url", ] [[package]] @@ -5764,9 +5765,9 @@ checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "winnow" -version = "0.5.27" +version = "0.5.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb877ca3232bec99a6472ed63f7241de2a250165260908b2d24c09d867907a85" +checksum = "6c830786f7720c2fd27a1a0e27a709dbd3c4d009b56d098fc742d4f4eab91fe2" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index dd89480..890d919 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ git = "https://github.com/pop-os/libcosmic" [workspace.dependencies.libcosmic] git = "https://github.com/pop-os/libcosmic" -features = ["wayland", "tokio", "single-instance"] +features = ["single-instance", "tokio", "wayland", "xdg-portal"] [workspace.dependencies.cosmic-config] git = "https://github.com/pop-os/libcosmic" diff --git a/app/src/app.rs b/app/src/app.rs index 34495e2..04e5295 100644 --- a/app/src/app.rs +++ b/app/src/app.rs @@ -18,6 +18,7 @@ use crate::subscription::desktop_files; use crate::widget::{page_title, search_header}; use crate::PageCommands; use cosmic::app::DbusActivationMessage; +use cosmic::dialog::file_chooser; use cosmic::iced::Subscription; use cosmic::{ app::{Command, Core}, @@ -45,6 +46,7 @@ pub struct SettingsApp { active_page: page::Entity, config: Config, core: Core, + file_chooser: Option<(file_chooser::Sender, page::Entity)>, nav_model: nav_bar::Model, pages: page::Binder, search: search::Model, @@ -65,10 +67,11 @@ impl SettingsApp { } } -#[allow(dead_code)] #[derive(Clone, Debug)] pub enum Message { DesktopInfo, + FileChooser(FileChooser), + Error(String), Page(page::Entity), PageMessage(crate::pages::Message), PanelConfig(CosmicPanelConfig), @@ -79,6 +82,21 @@ pub enum Message { SetTheme(cosmic::theme::Theme), } +#[derive(Clone, Debug)] +pub enum FileChooser { + Closed, + Init(file_chooser::Sender), + Open { + title: String, + accept_label: String, + include_directories: bool, + modal: bool, + multiple_files: bool, + }, + Opened, + Selected(Vec), +} + impl cosmic::Application for SettingsApp { type Executor = cosmic::executor::single::Executor; type Flags = crate::Args; @@ -99,6 +117,7 @@ impl cosmic::Application for SettingsApp { active_page: page::Entity::default(), config: Config::new(), core, + file_chooser: None, nav_model: nav_bar::Model::default(), pages: page::Binder::default(), search: search::Model::default(), @@ -212,6 +231,32 @@ impl cosmic::Application for SettingsApp { } }, ), + file_chooser::subscription(|response| match response { + file_chooser::Message::Closed => Message::FileChooser(FileChooser::Closed), + + file_chooser::Message::Opened => Message::FileChooser(FileChooser::Opened), + + file_chooser::Message::Selected(files) => { + Message::FileChooser(FileChooser::Selected(files.uris().to_owned())) + } + + file_chooser::Message::Err(why) => { + let mut source: &dyn std::error::Error = &why; + let mut string = + format!("open dialog subscription errored\n cause: {source}"); + + while let Some(new_source) = source.source() { + string.push_str(&format!("\n cause: {new_source}")); + source = new_source; + } + + Message::Error(string) + } + + file_chooser::Message::Init(sender) => { + Message::FileChooser(FileChooser::Init(sender)) + } + }), ]) } @@ -238,29 +283,39 @@ impl cosmic::Application for SettingsApp { crate::pages::Message::About(message) => { page::update!(self.pages, message, system::about::Page); } + crate::pages::Message::DateAndTime(message) => { page::update!(self.pages, message, time::date::Page); } + crate::pages::Message::Desktop(message) => { page::update!(self.pages, message, desktop::Page); } + crate::pages::Message::DesktopWallpaper(message) => { - page::update!(self.pages, message, desktop::wallpaper::Page); + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(cosmic::app::Message::App); + } } + crate::pages::Message::Input(message) => { if let Some(page) = self.pages.page_mut::() { return page.update(message).map(cosmic::app::Message::App); } } + crate::pages::Message::External { .. } => { todo!("external plugins not supported yet"); } + crate::pages::Message::Page(page) => { return self.activate_page(page); } + crate::pages::Message::Panel(message) => { page::update!(self.pages, message, panel::Page); } + crate::pages::Message::PanelApplet(message) => { if let Some(page) = self.pages.page_mut::() { return page @@ -268,19 +323,65 @@ impl cosmic::Application for SettingsApp { .map(cosmic::app::Message::App); } } + crate::pages::Message::Dock(message) => { page::update!(self.pages, message, dock::Page); } + crate::pages::Message::DockApplet(message) => { if let Some(page) = self.pages.page_mut::() { return page.update(message).map(cosmic::app::Message::App); } } + crate::pages::Message::Appearance(message) => { if let Some(page) = self.pages.page_mut::() { return page.update(message).map(cosmic::app::Message::App); } - // TODO + } + }, + + Message::FileChooser(message) => match message { + FileChooser::Selected(files) => { + return self.pages.page[self.active_page] + .file_chooser(files) + .map(crate::app::Message::PageMessage) + .map(cosmic::app::Message::App) + } + + FileChooser::Closed => {} + + FileChooser::Opened => { + if let Some((sender, _)) = self.file_chooser.as_mut() { + return sender.response().map(|_| cosmic::app::Message::None); + } + } + + FileChooser::Open { + title, + accept_label, + include_directories, + modal, + multiple_files, + } => { + if let Some((sender, entity)) = self.file_chooser.as_mut() { + if let Some(dialog) = file_chooser::open_file() { + *entity = self.active_page; + + return dialog + .title(title) + .accept_label(accept_label) + .include_directories(include_directories) + .modal(modal) + .multiple_files(multiple_files) + .create(sender) + .map(|_| cosmic::app::message::none()); + } + } + } + + FileChooser::Init(sender) => { + self.file_chooser = Some((sender, page::Entity::default())); } }, @@ -311,7 +412,7 @@ impl cosmic::Application for SettingsApp { self.pages, dock::applets::Message(applets_inner::Message::PanelConfig(config,)), dock::applets::Page - ) + ); } Message::DesktopInfo => { @@ -335,15 +436,23 @@ impl cosmic::Application for SettingsApp { .map(cosmic::app::Message::App); } } + Message::PanelConfig(_) | Message::Search(_) => {} + Message::SetTheme(t) => return cosmic::app::command::set_theme(t), + Message::OpenContextDrawer(title) => { self.core.window.show_context = true; self.set_context_title(title.to_string()); } + Message::CloseContextDrawer => { self.core.window.show_context = false; } + + Message::Error(error) => { + tracing::error!(error, "error occurred"); + } } Command::none() diff --git a/app/src/pages/desktop/wallpaper/config.rs b/app/src/pages/desktop/wallpaper/config.rs new file mode 100644 index 0000000..e6ea9a0 --- /dev/null +++ b/app/src/pages/desktop/wallpaper/config.rs @@ -0,0 +1,194 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::cosmic_config::{self, ConfigGet, ConfigSet}; +use cosmic_settings_desktop::wallpaper; +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; + +const NAME: &str = "com.system76.CosmicSettings.Wallpaper"; +const VERSION: u64 = 1; +const CURRENT_FOLDER: &str = "current-folder"; +const CUSTOM_COLORS: &str = "custom-colors"; +const CUSTOM_IMAGES: &str = "custom-images"; +const RECENT_FOLDERS: &str = "recent-folders"; + +#[derive(Debug, Default)] +pub struct Config { + context: Option, + current_folder: Option, + custom_colors: Vec, + custom_images: Vec, + recent_folders: VecDeque, +} + +impl Config { + pub fn new() -> Self { + let mut config = Self::default(); + + let context = match cosmic_config::Config::new(NAME, VERSION) { + Ok(context) => context, + Err(why) => { + tracing::warn!(?why, "failed to get config"); + return Self::default(); + } + }; + + if let Ok(path) = dbg!(context.get::(CURRENT_FOLDER)) { + config.current_folder = Some(path); + } + + if let Ok(colors) = context.get::>(CUSTOM_COLORS) { + config.custom_colors = colors; + } + + if let Ok(images) = context.get::>(CUSTOM_IMAGES) { + config.custom_images = images; + } + + if let Ok(folders) = dbg!(context.get::>(RECENT_FOLDERS)) { + config.recent_folders = folders; + } + + config.context = Some(context); + + config + } + + #[must_use] + pub fn current_folder(&self) -> &Path { + self.current_folder + .as_deref() + .unwrap_or(Path::new("/usr/share/backgrounds/")) + } + + /// Sets the current background folder + /// + /// # Errors + /// + /// Returns an error if the on-disk configuration could not be updated. + pub fn set_current_folder(&mut self, folder: PathBuf) -> Result<(), cosmic_config::Error> { + let result = self.update(CURRENT_FOLDER, &folder); + self.current_folder = Some(folder); + result + } + + #[must_use] + pub fn custom_colors(&self) -> &[wallpaper::Color] { + &self.custom_colors + } + + /// Adds a custom color + /// + /// # Errors + /// + /// Returns an error if the on-disk configuration could not be updated. + pub fn add_custom_color( + &mut self, + color: wallpaper::Color, + ) -> Result<(), cosmic_config::Error> { + if !self.custom_colors.contains(&color) { + self.custom_colors.push(color); + return self.update_custom_colors(); + } + + Ok(()) + } + + /// Removes custom background colors. + /// + /// # Errors + /// + /// Returns an error if the on-disk configuration could not be updated. + pub fn remove_custom_color( + &mut self, + color: &wallpaper::Color, + ) -> Result<(), cosmic_config::Error> { + if let Some(position) = self.custom_colors.iter().position(|c| c == color) { + self.custom_colors.remove(position); + return self.update_custom_colors(); + } + + Ok(()) + } + + #[must_use] + pub fn custom_images(&self) -> &[PathBuf] { + &self.custom_images + } + + /// Adds a custom background image + /// + /// # Errors + /// + /// Returns an error if the on-disk configuration could not be updated. + pub fn add_custom_image(&mut self, image: PathBuf) -> Result<(), cosmic_config::Error> { + if !self.custom_images.contains(&image) { + self.custom_images.push(image); + return self.update_custom_images(); + } + + Ok(()) + } + + /// Removes custom background images. + /// + /// # Errors + /// + /// Returns an error if the on-disk configuration could not be updated. + pub fn remove_custom_image(&mut self, image: &Path) -> Result<(), cosmic_config::Error> { + if let Some(position) = self.custom_images.iter().position(|p| p == image) { + self.custom_images.remove(position); + return self.update_custom_images(); + } + + Ok(()) + } + + #[must_use] + pub fn recent_folders(&self) -> &VecDeque { + &self.recent_folders + } + + /// Adds a folder to the recent folders list + /// + /// # Errors + /// + /// Returns an error if the on-disk configuration could not be updated. + pub fn add_recent_folder(&mut self, folder: PathBuf) -> Result<(), cosmic_config::Error> { + while self.recent_folders.len() > 4 { + self.recent_folders.pop_front(); + } + + if !self.recent_folders.contains(&folder) { + self.recent_folders.push_back(folder); + return self.update_recent_folders(); + } + + Ok(()) + } + + fn update( + &self, + key: &str, + value: &V, + ) -> Result<(), cosmic_config::Error> { + if let Some(context) = self.context.as_ref() { + context.set(key, value)?; + } + + Ok(()) + } + + fn update_custom_colors(&self) -> Result<(), cosmic_config::Error> { + self.update(CUSTOM_COLORS, &self.custom_colors) + } + + fn update_custom_images(&self) -> Result<(), cosmic_config::Error> { + self.update(CUSTOM_IMAGES, &self.custom_images) + } + + fn update_recent_folders(&self) -> Result<(), cosmic_config::Error> { + self.update(RECENT_FOLDERS, &self.recent_folders) + } +} diff --git a/app/src/pages/desktop/wallpaper/mod.rs b/app/src/pages/desktop/wallpaper/mod.rs index 6ec67d0..c0635fe 100644 --- a/app/src/pages/desktop/wallpaper/mod.rs +++ b/app/src/pages/desktop/wallpaper/mod.rs @@ -1,33 +1,36 @@ // Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only +mod config; pub mod widgets; +pub use config::Config; + use std::{ collections::HashMap, path::{Path, PathBuf}, + sync::Arc, }; use apply::Apply; -use cosmic::widget::{ - dropdown, list_column, - segmented_button::{self, SingleSelectModel}, - segmented_selection, settings, text, toggler, -}; use cosmic::{iced::Length, Element}; use cosmic::{iced_core::alignment, iced_runtime::core::image::Handle as ImageHandle}; +use cosmic::{ + widget::{ + button, dropdown, list_column, row, + segmented_button::{self, SingleSelectModel}, + segmented_selection, settings, text, toggler, + }, + Command, +}; use cosmic_settings_desktop::wallpaper::{self, Entry, ScalingMode}; use cosmic_settings_page::Section; use cosmic_settings_page::{self as page, section}; use image::imageops::FilterType::Lanczos3; +use image::{ImageBuffer, Rgba}; use slotmap::{DefaultKey, SecondaryMap, SlotMap}; use static_init::dynamic; -const SYSTEM_WALLPAPER_DIR: &str = "/usr/share/backgrounds/pop/"; - -const CATEGORY_SYSTEM_WALLPAPERS: usize = 0; -const CATEGORY_COLOR: usize = 1; - const FIT: usize = 0; const STRETCH: usize = 1; const ZOOM: usize = 2; @@ -42,33 +45,44 @@ const MINUTES_30: usize = 3; const HOUR_1: usize = 4; const HOUR_2: usize = 5; +pub type Image = ImageBuffer, Vec>; + #[derive(Clone, Debug)] pub enum Message { - ChangeCategory(usize), + ChangeFolder(Context), + ColorAdd(wallpaper::Color), + ColorAddDialog, + ColorRemove(wallpaper::Color), + ChangeCategory(Category), ColorSelect(wallpaper::Color), Fit(usize), + ImageAdd(Option>), + ImageAddDialog, + ImageRemove(DefaultKey), Output(segmented_button::Entity), RotationFrequency(usize), SameBackground(bool), Select(DefaultKey), Slideshow(bool), - Update(Box<(wallpaper::Config, HashMap, Context)>), + Init(Box<(wallpaper::Config, HashMap, Context)>), } +#[derive(Clone, Debug, PartialEq)] pub enum Category { - SystemBackgrounds, + Backgrounds, Colors, + RecentFolder(usize), } pub struct Page { pub active_output: Option, - pub active_category: usize, + pub background_service_config: wallpaper::Config, pub cached_display_handle: Option, - pub categories: Vec, - pub config: wallpaper::Config, - pub current_directory: PathBuf, + pub categories: dropdown::multi::Model, + pub config: Config, pub fit_options: Vec, pub outputs: SingleSelectModel, + pub recent_folders: Vec<(PathBuf, String)>, pub rotation_frequency: u64, pub rotation_options: Vec, pub selected_fit: usize, @@ -76,17 +90,93 @@ pub struct Page { pub selection: Context, } +impl page::Page for Page { + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(settings())]) + } + + fn info(&self) -> page::Info { + page::Info::new("wallpaper", "preferences-desktop-wallpaper-symbolic") + .title(fl!("wallpaper")) + .description(fl!("wallpaper", "desc")) + } + + fn file_chooser(&mut self, selections: Vec) -> Command { + if let Some(selection) = selections.first() { + let path = PathBuf::from(selection.path()); + + if path.is_dir() { + self.add_recent_folder(path); + } else { + if let Some(parent) = path.parent() { + self.add_recent_folder(parent.to_owned()); + } + + return cosmic::command::future(async move { + let result = wallpaper::load_image_with_thumbnail(&mut Vec::new(), path).await; + + crate::pages::Message::DesktopWallpaper(Message::ImageAdd(result.map(Arc::new))) + }); + } + } + + Command::none() + } + + fn load(&self, _page: page::Entity) -> Option> { + let current_folder = self.config.current_folder().to_owned(); + + Some(Box::pin(async move { + let (background_service_config, outputs) = wallpaper::config(); + + let update = change_folder(current_folder).await; + + crate::pages::Message::DesktopWallpaper(Message::Init(Box::new(( + background_service_config, + outputs, + update, + )))) + })) + } +} + +impl page::AutoBind for Page {} + impl Default for Page { fn default() -> Self { - Page { + let mut page = Page { active_output: None, - active_category: CATEGORY_SYSTEM_WALLPAPERS, cached_display_handle: None, - categories: vec![fl!("system-backgrounds"), fl!("colors")], - config: wallpaper::Config::default(), - current_directory: PathBuf::from(SYSTEM_WALLPAPER_DIR), + categories: { + let mut categories = dropdown::multi::model(); + + categories.insert(dropdown::multi::list( + None, + vec![(fl!("system-backgrounds"), Category::Backgrounds)], + )); + + categories.insert(dropdown::multi::list( + None, + vec![(fl!("colors"), Category::Colors)], + )); + + categories.insert(dropdown::multi::list( + Some(fl!("recent-folders")), + Vec::with_capacity(5), + )); + + categories.selected = Some(Category::Backgrounds); + + categories + }, + background_service_config: wallpaper::Config::default(), + config: Config::new(), fit_options: vec![fl!("fit-to-screen"), fl!("stretch"), fl!("zoom")], outputs: SingleSelectModel::default(), + recent_folders: Vec::new(), rotation_frequency: 300, rotation_options: vec![ // FIX: fluent is inserting extra unicode characters in formatting @@ -112,32 +202,42 @@ impl Default for Page { selected_fit: 0, selected_rotation: 0, selection: Context::default(), + }; + + // Sync custom colors from config. + for color in page.config.custom_colors() { + page.selection.add_custom_color(color.clone()); } + + page.assign_recent_folders(); + + page } } -#[derive(Clone, Debug, PartialEq)] -enum Choice { - Background(DefaultKey), - Color(wallpaper::Color), - Slideshow, -} - -impl Default for Choice { - fn default() -> Self { - Self::Background(DefaultKey::default()) - } -} - -#[derive(Clone, Debug, Default)] -pub struct Context { - active: Choice, - paths: SlotMap, - display_images: SecondaryMap, - selection_handles: SecondaryMap, -} - impl Page { + fn add_recent_folder(&mut self, folder: PathBuf) { + if let Err(why) = self.config.add_recent_folder(folder) { + tracing::error!(?why, "cannot add recent folder to config"); + } + + self.assign_recent_folders(); + } + + fn assign_recent_folders(&mut self) { + let recent_list = &mut self.categories.lists[2]; + recent_list.options.clear(); + + for (id, folder) in self.config.recent_folders().iter().enumerate() { + if let Some(name) = folder.file_name() { + let name = name.to_string_lossy(); + recent_list + .options + .push((name.to_string(), Category::RecentFolder(id))); + } + } + } + fn cache_display_image(&mut self) { let choice = match self.selection.active { Choice::Background(id) => self.selection.display_images.get(id), @@ -204,7 +304,7 @@ impl Page { } fn config_output(&self) -> Option { - if self.config.same_on_all { + if self.background_service_config.same_on_all { Some(String::from("all")) } else { self.outputs.active_data::().cloned() @@ -217,13 +317,16 @@ impl Page { return; }; - if self.config.same_on_all { - self.config.outputs.clear(); + if self.background_service_config.same_on_all { + self.background_service_config.outputs.clear(); } let entry = match self.selection.active { Choice::Slideshow => { - match self.config_background_entry(output.clone(), self.current_directory.clone()) { + match self.config_background_entry( + output.clone(), + self.config.current_folder().to_path_buf(), + ) { Some(entry) => entry, None => return, } @@ -245,13 +348,14 @@ impl Page { }; if output != "all" { - self.config.backgrounds.clear(); - self.config.outputs.clear(); + self.background_service_config.backgrounds.clear(); + self.background_service_config.outputs.clear(); } - wallpaper::set(&mut self.config, entry); + wallpaper::set(&mut self.background_service_config, entry); } + /// Updates configuration for background image. fn config_background_entry(&self, output: String, path: PathBuf) -> Option { let scaling_mode = match self.selected_fit { FIT => ScalingMode::Fit([0.0, 0.0, 0.0]), @@ -266,13 +370,14 @@ impl Page { .apply(Some) } - fn config_update( + /// Updates configuration from the background service. + fn background_service_config_update( &mut self, - config: wallpaper::Config, + background_service_config: wallpaper::Config, displays: HashMap, selection: Context, ) { - self.config = config; + self.background_service_config = background_service_config; self.selection = selection; self.outputs.clear(); @@ -293,49 +398,78 @@ impl Page { self.outputs.activate(id); } - if self.config.same_on_all || self.config.backgrounds.is_empty() { - let entry = self.config.default_background.clone(); + if self.background_service_config.same_on_all + || self.background_service_config.backgrounds.is_empty() + { + let entry = self.background_service_config.default_background.clone(); self.select_background_entry(&entry); - if let Some(current) = entry_directory(&entry) { - self.current_directory = current; + if let Some(current) = entry_directory(self.config.current_folder(), &entry) { + if let Err(why) = self.config.set_current_folder(current) { + tracing::error!(?why, "cannot set current folder"); + } } } else if let Some(data) = self.outputs.active_data::() { let mut backgrounds = Vec::new(); - std::mem::swap(&mut self.config.backgrounds, &mut backgrounds); + std::mem::swap( + &mut self.background_service_config.backgrounds, + &mut backgrounds, + ); for background in &backgrounds { if &background.output == data { self.active_output = Some(data.clone()); self.select_background_entry(background); - if let Some(current) = entry_directory(background) { - self.current_directory = current; + if let Some(current) = entry_directory(self.config.current_folder(), background) + { + if let Err(why) = self.config.set_current_folder(current) { + tracing::error!(?why, "cannot set current folder"); + } } break; } } - std::mem::swap(&mut self.config.backgrounds, &mut backgrounds); + std::mem::swap( + &mut self.background_service_config.backgrounds, + &mut backgrounds, + ); } } /// Changes the selection category, such as wallpaper select or color select. - fn change_category(&mut self, pos: usize) { - self.active_category = pos; - match pos { - CATEGORY_SYSTEM_WALLPAPERS => { + fn change_category(&mut self, category: Category) -> Command { + let mut command = Command::none(); + + match category { + Category::Backgrounds => { self.select_first_background(); } - CATEGORY_COLOR => { + Category::Colors => { self.selection.active = Choice::Color(wallpaper::DEFAULT_COLORS[0].clone()); self.cache_display_image(); } - _ => (), + Category::RecentFolder(id) => { + if let Some(path) = self.config.recent_folders().get(id).cloned() { + if let Err(why) = self.config.set_current_folder(path.clone()) { + tracing::error!(?path, ?why, "failed to set current folder"); + } + + command = cosmic::command::future(async move { + crate::app::Message::PageMessage(crate::pages::Message::DesktopWallpaper( + Message::ChangeFolder(change_folder(path).await), + )) + }); + } + } } + + self.categories.selected = Some(category); + command } /// Changes the output being configured @@ -346,7 +480,7 @@ impl Page { } } - // Changes the slideshow background rotation frequency + /// Changes the slideshow background rotation frequency pub fn change_rotation_frequency(&mut self, option: usize) { self.selected_rotation = option; @@ -373,9 +507,86 @@ impl Page { } #[allow(clippy::too_many_lines)] - pub fn update(&mut self, message: Message) { + pub fn update(&mut self, message: Message) -> Command { match message { - Message::ChangeCategory(pos) => self.change_category(pos), + Message::ChangeFolder(mut context) => { + // Reassign custom colors and images to the new context. + std::mem::swap(&mut context, &mut self.selection); + + for color in context.custom_colors { + self.selection.add_custom_color(color); + } + + for image in context.custom_images { + let path = context.paths.remove(image); + let display = context.display_images.remove(image); + let selection = context.selection_handles.remove(image); + + if let Some(((display, selection), path)) = display.zip(selection).zip(path) { + let key = self.selection.paths.insert(path); + self.selection.display_images.insert(key, display); + self.selection.selection_handles.insert(key, selection); + } + } + + self.select_first_background(); + } + Message::ColorAdd(color) => { + if let Err(why) = self.config.add_custom_color(color) { + tracing::error!(?why, "could not set custom color"); + } + } + + Message::ColorAddDialog => { + unimplemented!(); + } + + Message::ColorRemove(color) => { + self.selection.remove_custom_color(&color); + + if let Err(why) = self.config.remove_custom_color(&color) { + tracing::error!(?why, "could not remove custom color from config"); + } + } + + Message::ImageAdd(result) => { + let result = result.and_then(Arc::into_inner); + + let Some((path, display, selection)) = result else { + tracing::warn!("image not found for provided wallpaper"); + return Command::none(); + }; + + if let Err(why) = self.config.add_custom_image(path.clone()) { + tracing::error!(?path, ?why, "could add custom image to config"); + } + + self.selection.add_custom_image(path, display, selection); + } + + Message::ImageAddDialog => { + return cosmic::command::message(crate::Message::FileChooser( + crate::app::FileChooser::Open { + title: fl!("wallpaper-dialog-image"), + accept_label: fl!("wallpaper-dialog-image", "accept"), + include_directories: false, + modal: false, + multiple_files: false, + }, + )); + } + + Message::ImageRemove(image) => { + if let Some(path) = self.selection.remove_custom_image(image) { + if let Err(why) = self.config.remove_custom_image(&path) { + tracing::error!(?why, "could not remove custom image from config"); + } + } + } + + Message::ChangeCategory(category) => { + return self.change_category(category); + } Message::ColorSelect(color) => { self.selection.active = Choice::Color(color); @@ -392,8 +603,8 @@ impl Page { Message::RotationFrequency(pos) => self.change_rotation_frequency(pos), Message::SameBackground(value) => { - self.config.same_on_all = value; - self.config.backgrounds.clear(); + self.background_service_config.same_on_all = value; + self.background_service_config.backgrounds.clear(); } Message::Select(id) => { @@ -410,10 +621,30 @@ impl Page { } } - Message::Update(update) => self.config_update(update.0, update.1, update.2), + Message::Init(update) => { + self.background_service_config_update(update.0, update.1, update.2); + self.config_apply(); + + // Load custom content + return cosmic::command::batch(self.config.custom_images().iter().cloned().map( + |path| { + cosmic::command::future(async move { + let result = + wallpaper::load_image_with_thumbnail(&mut Vec::new(), path).await; + + crate::app::Message::PageMessage( + crate::pages::Message::DesktopWallpaper(Message::ImageAdd( + result.map(Arc::new), + )), + ) + }) + }, + )); + } } self.config_apply(); + Command::none() } /// Selects the given background entry. @@ -430,7 +661,7 @@ impl Page { wallpaper::Source::Color(ref color) => { self.selection.active = Choice::Color(color.clone()); - self.active_category = CATEGORY_COLOR; + self.categories.selected = Some(Category::Colors); self.cache_display_image(); } } @@ -438,11 +669,17 @@ impl Page { /// Selects the first background from the wallpaper select options. fn select_first_background(&mut self) { - if let Some((entity, path)) = self.selection.paths.iter().next() { - if let Some(output) = self.config_output() { - if let Some(entry) = self.config_background_entry(output, path.clone()) { - self.select_background(&entry, entity, path.is_dir()); - } + let (entity, path) = match self.selection.custom_images.last() { + Some(entity) => (*entity, &self.selection.paths[*entity]), + None => match self.selection.paths.iter().next() { + Some(value) => value, + None => return, + }, + }; + + if let Some(output) = self.config_output() { + if let Some(entry) = self.config_background_entry(output, path.clone()) { + self.select_background(&entry, entity, path.is_dir()); } } } @@ -490,49 +727,89 @@ impl Page { } } -impl page::Page for Page { - fn content( - &self, - sections: &mut SlotMap>, - ) -> Option { - Some(vec![sections.insert(settings())]) - } +#[derive(Clone, Debug, PartialEq)] +enum Choice { + Background(DefaultKey), + Color(wallpaper::Color), + Slideshow, +} - fn info(&self) -> page::Info { - page::Info::new("wallpaper", "preferences-desktop-wallpaper-symbolic") - .title(fl!("wallpaper")) - .description(fl!("wallpaper", "desc")) - } - - fn load(&self, _page: page::Entity) -> Option> { - Some(Box::pin(async move { - let (config, outputs) = wallpaper::config(); - - let mut backgrounds = wallpaper::load_each_from_path(SYSTEM_WALLPAPER_DIR.into()); - - let mut update = Context::default(); - - while let Some((path, display_image, selection_image)) = backgrounds.recv().await { - let id = update.paths.insert(path); - - update.display_images.insert(id, display_image); - - let selection_handle = ImageHandle::from_pixels( - selection_image.width(), - selection_image.height(), - selection_image.into_vec(), - ); - update.selection_handles.insert(id, selection_handle); - } - - crate::pages::Message::DesktopWallpaper(Message::Update(Box::new(( - config, outputs, update, - )))) - })) +impl Default for Choice { + fn default() -> Self { + Self::Background(DefaultKey::default()) } } -impl page::AutoBind for Page {} +#[derive(Clone, Debug, Default)] +pub struct Context { + active: Choice, + custom_images: Vec, + custom_colors: Vec, + paths: SlotMap, + is_custom: SecondaryMap, + display_images: SecondaryMap, + selection_handles: SecondaryMap, +} + +impl Context { + fn add_custom_color(&mut self, color: wallpaper::Color) { + if !self.custom_colors.contains(&color) { + self.add_custom_color(color); + } + } + + fn add_custom_image(&mut self, path: PathBuf, display: Image, selection: Image) { + let key = self.paths.insert(path); + self.is_custom.insert(key, true); + self.display_images.insert(key, display); + self.custom_images.push(key); + self.selection_handles.insert( + key, + ImageHandle::from_pixels(selection.width(), selection.height(), selection.into_vec()), + ); + } + + fn remove_custom_color(&mut self, color: &wallpaper::Color) { + if let Some(id) = self.custom_colors.iter().position(|c| c == color) { + self.custom_colors.remove(id); + } + } + + fn remove_custom_image(&mut self, image: DefaultKey) -> Option { + if let Some(true) = self.is_custom.remove(image) { + if let Some(id) = self.custom_images.iter().position(|i| i == &image) { + self.custom_images.remove(id); + } + + self.display_images.remove(image); + self.selection_handles.remove(image); + return self.paths.remove(image); + } + + None + } +} + +pub async fn change_folder(current_folder: PathBuf) -> Context { + let mut update = Context::default(); + let mut backgrounds = wallpaper::load_each_from_path(current_folder); + + while let Some((path, display_image, selection_image)) = backgrounds.recv().await { + let id = update.paths.insert(path); + + update.display_images.insert(id, display_image); + + let selection_handle = ImageHandle::from_pixels( + selection_image.width(), + selection_image.height(), + selection_image.into_vec(), + ); + + update.selection_handles.insert(id, selection_handle); + } + + update +} #[dynamic] static WALLPAPER_SAME: String = fl!("wallpaper", "same"); @@ -580,7 +857,7 @@ pub fn settings() -> Section { }, )); - children.push(if page.config.same_on_all { + children.push(if page.background_service_config.same_on_all { text(fl!("all-displays")) .font(cosmic::font::FONT_SEMIBOLD) .horizontal_alignment(alignment::Horizontal::Center) @@ -604,7 +881,11 @@ pub fn settings() -> Section { let mut column = list_column() .add(settings::item( &*WALLPAPER_SAME, - toggler(None, page.config.same_on_all, Message::SameBackground), + toggler( + None, + page.background_service_config.same_on_all, + Message::SameBackground, + ), )) .add(settings::item(&*WALLPAPER_FIT, background_fit)); @@ -632,17 +913,30 @@ pub fn settings() -> Section { } }); - let category_selection = dropdown( - &page.categories, - Some(page.active_category), - Message::ChangeCategory, + let category_selection = + dropdown::multi::dropdown(&page.categories, Message::ChangeCategory); + + let add_button = { + let (text, message) = if Some(Category::Colors) == page.categories.selected { + (fl!("add-color"), Message::ColorAddDialog) + } else { + (fl!("add-image"), Message::ImageAddDialog) + }; + + button::link(text).on_press(message) + }; + + children.push( + row::with_capacity(2) + .push(category_selection) + .push(cosmic::widget::horizontal_space(Length::Fill)) + .push(add_button) + .into(), ); - children.push(category_selection.into()); - - match page.active_category { + match page.categories.selected { // Displays system wallpapers that are available to select from - CATEGORY_SYSTEM_WALLPAPERS => { + Some(Category::Backgrounds | Category::RecentFolder(_)) => { children.push(widgets::wallpaper_select_options( page, if let Choice::Background(selection) = page.selection.active { @@ -654,8 +948,9 @@ pub fn settings() -> Section { } // Displays colors and gradients that are available to select from - CATEGORY_COLOR => { + Some(Category::Colors) => { children.push(widgets::color_select_options( + &page.selection, if let Choice::Color(ref color) = page.selection.active { Some(color) } else { @@ -664,7 +959,7 @@ pub fn settings() -> Section { )); } - _ => (), + None => (), } cosmic::widget::column::with_children(children) @@ -675,7 +970,7 @@ pub fn settings() -> Section { } /// Sets the current wallpaper directory. -fn entry_directory(entry: &wallpaper::Entry) -> Option { +fn entry_directory(current_folder: &Path, entry: &wallpaper::Entry) -> Option { Some(match entry.source { wallpaper::Source::Path(ref path) => { if path.is_dir() { @@ -687,6 +982,6 @@ fn entry_directory(entry: &wallpaper::Entry) -> Option { } } - wallpaper::Source::Color(_) => PathBuf::from(SYSTEM_WALLPAPER_DIR), + wallpaper::Source::Color(_) => PathBuf::from(current_folder), }) } diff --git a/app/src/pages/desktop/wallpaper/widgets.rs b/app/src/pages/desktop/wallpaper/widgets.rs index be65be2..9358655 100644 --- a/app/src/pages/desktop/wallpaper/widgets.rs +++ b/app/src/pages/desktop/wallpaper/widgets.rs @@ -15,8 +15,19 @@ const COLUMN_SPACING: u16 = 12; const ROW_SPACING: u16 = 16; /// A button for selecting a color or gradient. -pub fn color_button(color: wallpaper::Color, selected: bool) -> Element<'static, Message> { - button(color_image(color.clone(), COLOR_WIDTH, COLOR_WIDTH, 8.0)) +pub fn color_button( + color: wallpaper::Color, + removable: bool, + selected: bool, +) -> Element<'static, Message> { + let content = color_image(color.clone(), COLOR_WIDTH, COLOR_WIDTH, 8.0); + let on_remove = if removable { + Some(Message::ColorRemove(color.clone())) + } else { + None + }; + + button::custom_image_button(content, on_remove) .padding(0) .selected(selected) .style(button::Style::Image) @@ -65,12 +76,26 @@ pub fn color_image<'a, M: 'a>( } /// Color selection list -pub fn color_select_options(selected: Option<&wallpaper::Color>) -> Element<'static, Message> { +pub fn color_select_options( + context: &super::Context, + selected: Option<&wallpaper::Color>, +) -> Element<'static, Message> { let mut vec = Vec::with_capacity(wallpaper::DEFAULT_COLORS.len()); + // Place removable custom colors first + for color in context.custom_colors.iter().rev() { + vec.push(color_button( + color.clone(), + true, + selected.map_or(false, |selection| selection == color), + )); + } + + // Then non-removable default colors for color in wallpaper::DEFAULT_COLORS { vec.push(color_button( color.clone(), + false, selected.map_or(false, |selection| selection == color), )); } @@ -85,10 +110,28 @@ pub fn wallpaper_select_options( ) -> Element { let mut vec = Vec::with_capacity(page.selection.selection_handles.len()); + // Place removable custom images first + for id in page.selection.custom_images.iter().rev() { + let handle = &page.selection.selection_handles[*id]; + + vec.push(wallpaper_button( + handle, + *id, + true, + selected.map_or(false, |selection| id == &selection), + )); + } + + // Then place non-removable images from the current folder for (id, handle) in &page.selection.selection_handles { + if page.selection.is_custom.contains_key(id) { + continue; + } + vec.push(wallpaper_button( handle, id, + false, selected.map_or(false, |selection| id == selection), )); } @@ -106,9 +149,19 @@ fn flex_select_row(elements: Vec>) -> Element { .into() } -fn wallpaper_button(handle: &ImageHandle, id: DefaultKey, selected: bool) -> Element { +fn wallpaper_button( + handle: &ImageHandle, + id: DefaultKey, + removable: bool, + selected: bool, +) -> Element { cosmic::widget::button::image(handle.clone()) .selected(selected) .on_press(Message::Select(id)) + .on_remove_maybe(if removable { + Some(Message::ImageRemove(id)) + } else { + None + }) .into() } diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 2da48d5..88049cc 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -13,68 +13,76 @@ desktop = Desktop appearance = Appearance .desc = Accent colors and COSMIC theming. -import = Import -export = Export - -mode-and-colors = Mode and Colors -auto-switch = Automatically switch from Light to Dark mode - .desc = Switches to Light mode at sunrise accent-color = Accent color app-background = Application or window background auto = Auto close = Close +color-picker = Color Picker +copied-to-clipboard = Copied to clipboard +copy-to-clipboard = Copy to clipboard +dark = Dark +export = Export +hex = Hex +import = Import +light = Light +mode-and-colors = Mode and Colors +recent-colors = Recent colors +reset-default = Reset to default +reset-to-default = Reset to default +rgb = RGB +window-hint-accent = Active window hint color +window-hint-accent-toggle = Use theme accent color as active window hint + +auto-switch = Automatically switch from Light to Dark mode + .desc = Switches to Light mode at sunrise + container-background = Container background .desc-detail = Container background color is used for navigation sidebar, side drawer, dialogs and similar widgets. By default, it is automatically derived from the Application or window background. .reset = Reset to auto .desc = Primary container color is used for navigation sidebar, side drawer, dialogs and similar widgets. -text-tint = Interface text tint - .desc = Color used to derive interface text colors that have sufficient contrast on various surfaces. + control-tint = Control component tint .desc = Used for backgrounds of standard buttons, search inputs, text inputs, and similar components. -window-hint-accent-toggle = Use theme accent color as active window hint -window-hint-accent = Active window hint color -dark = Dark -light = Light -color-picker = Color Picker -hex = Hex -rgb = RGB -recent-colors = Recent colors -reset-to-default = Reset to default -copy-to-clipboard = Copy to clipboard -copied-to-clipboard = Copied to clipboard + +frosted = Frosted glass effect on system interface + .desc = Applies background blur to panel, dock, applets, launcher, and application library. + +text-tint = Interface text tint + .desc = Color used to derive interface text colors that have sufficient contrast on various surfaces. style = Style .round = Round .slightly-round = Slightly round .square = Square -frosted = Frosted glass effect on system interface - .desc = Applies background blur to panel, dock, applets, launcher, and application library. -reset-default = Reset to default # interface density left out for now - window-management = Window Management .active-hint = Active window hint size .gaps = Gaps around tiled windows + ## Desktop: Notifications notifications = Notifications .desc = Do Not Disturb, lockscreen notifications, and per-application settings. - ## Desktop: Options desktop-panel-options = Desktop and Panel .desc = Super Key action, hot corners, window control options. +desktop-panels-and-applets = Desktop Panels and Applets + +dock = Dock + .desc = Panel with pinned applications. + +hot-corner = Hot Corner + .top-left-corner = Enable top-left hot corner for Workspaces + super-key-action = Super Key Action .launcher = Launcher .workspaces = Workspaces .applications = Applications -hot-corner = Hot Corner - .top-left-corner = Enable top-left hot corner for Workspaces - top-panel = Top Panel .workspaces = Show Workspaces Button .applications = Show Applications Button @@ -83,32 +91,39 @@ window-controls = Window Controls .minimize = Show Minimize Button .maximize = Show Maximize Button -desktop-panels-and-applets = Desktop Panels and Applets - - -dock = Dock - .desc = Panel with pinned applications. - ## Desktop: Panel + panel = Panel .desc = Top bar with desktop controls and menus. +add = Add +add-applet = Add Applet +all = All +applets = Applets +center-segment = Center Segment +drop-here = Drop applets here +end-segment = End Segment +large = Large +no-applets-found = No applets found... +panel-bottom = Bottom +panel-left = Left +panel-right = Right +panel-top = Top +search-applets = Search applets... +small = Small +start-segment = Start Segment + +panel-appearance = Appearance + .match = Match desktop + .light = Light + .dark = Dark + panel-behavior-and-position = Behavior and Positions .autohide = Automatically hide panel .dock-autohide = Automatically hide dock .position = Position on screen .display = Show on display -panel-top = Top -panel-bottom = Bottom -panel-left = Left -panel-right = Right - -panel-appearance = Appearance - .match = Match desktop - .light = Light - .dark = Dark - panel-style = Style .anchor-gap = Gap between panel and screen edges .dock-anchor-gap = Gap between dock and screen edges @@ -118,9 +133,6 @@ panel-style = Style .size = Size .background-opacity = Background opacity -small = Small -large = Large - panel-applets = Configuration .dock-desc = Configure dock applets. .desc = Configure panel applets. @@ -129,19 +141,6 @@ panel-missing = Panel Configuration is Missing .desc = The panel configuration file is missing due to use of a custom configuration or it is corrupted. .fix = Reset to default -applets = Applets -start-segment = Start Segment -center-segment = Center Segment -end-segment = End Segment - -add = Add -add-applet = Add Applet -search-applets = Search applets... -no-applets-found = No applets found... -all = All - -drop-here = Drop applets here - ## Desktop: Wallpaper wallpaper = Wallpaper @@ -151,13 +150,19 @@ wallpaper = Wallpaper .slide = Slideshow .change = Change image every +add-color = Add color +add-image = Add image all-displays = All Displays colors = Colors fit-to-screen = Fit to Screen +recent-folders = Recent Folders stretch = Stretch system-backgrounds = System backgrounds zoom = Zoom +wallpaper-dialog-image = Choose wallpaper image + .accept = _Add + x-minutes = { $number } minutes x-hours = { $number -> [1] 1 hour diff --git a/page/Cargo.toml b/page/Cargo.toml index 2faf9db..a8e3a76 100644 --- a/page/Cargo.toml +++ b/page/Cargo.toml @@ -11,3 +11,4 @@ libcosmic = { workspace = true } generator = "0.7.4" downcast-rs = "1.2.0" once_cell = "1.17.2" +url = "2.5.0" diff --git a/page/src/lib.rs b/page/src/lib.rs index 92afa6e..9430ff3 100644 --- a/page/src/lib.rs +++ b/page/src/lib.rs @@ -44,6 +44,11 @@ pub trait Page: Downcast { None } + /// Response from a file chooser dialog request. + fn file_chooser(&mut self, _selected: Vec) -> Command { + Command::none() + } + #[must_use] #[allow(unused)] fn load(&self, page: crate::Entity) -> Option> { diff --git a/pages/desktop/src/wallpaper.rs b/pages/desktop/src/wallpaper.rs index d4a285e..505abfe 100644 --- a/pages/desktop/src/wallpaper.rs +++ b/pages/desktop/src/wallpaper.rs @@ -1,6 +1,6 @@ pub use cosmic_bg_config::{Color, Config, Entry, Gradient, ScalingMode, Source}; -use image::{DynamicImage, RgbaImage}; +use image::{DynamicImage, ImageBuffer, Rgba, RgbaImage}; use std::{ borrow::Cow, collections::{hash_map::DefaultHasher, BTreeSet, HashMap}, @@ -101,7 +101,7 @@ pub fn load_each_from_path(path: PathBuf) -> Receiver<(PathBuf, RgbaImage, RgbaI let (tx, rx) = mpsc::channel(1); - tokio::task::spawn_blocking(move || { + tokio::task::spawn(async move { let mut buffer = Vec::new(); let mut paths = vec![path]; let mut wallpapers = BTreeSet::new(); @@ -125,52 +125,8 @@ pub fn load_each_from_path(path: PathBuf) -> Receiver<(PathBuf, RgbaImage, RgbaI } for path in wallpapers { - let image_operation = load_thumbnail(&mut buffer, cache_dir.as_deref(), &path); - - if let Some(image_operation) = image_operation { - let tokio_handle = tokio::runtime::Handle::current(); - let tx = tx.clone(); - - rayon::spawn_fifo(move || { - let display_thumbnail = match image_operation { - ImageOperation::Cached(thumbnail) => thumbnail.to_rgba8(), - - ImageOperation::GenerateThumbnail { path, image } => { - let image = image.thumbnail(300, 169).to_rgba8(); - - if let Some(path) = path { - // Save thumbnail to disk without blocking. - tokio_handle.spawn_blocking({ - let image = image.clone(); - move || { - if let Err(why) = image.save(&path) { - tracing::error!( - ?path, - ?why, - "failed to save image thumbnail" - ); - - let _res = std::fs::remove_file(&path); - } - } - }); - } - - image - } - }; - - let mut selection_thumbnail = image::imageops::resize( - &display_thumbnail, - 158, - 105, - image::imageops::FilterType::Lanczos3, - ); - - round(&mut selection_thumbnail, [8, 8, 8, 8]); - - let _res = tx.blocking_send((path, display_thumbnail, selection_thumbnail)); - }); + if let Some(value) = load_image_with_thumbnail(&mut buffer, path).await { + let _res = tx.send(value).await; } } }); @@ -178,6 +134,65 @@ pub fn load_each_from_path(path: PathBuf) -> Receiver<(PathBuf, RgbaImage, RgbaI rx } +pub async fn load_image_with_thumbnail( + buffer: &mut Vec, + path: PathBuf, +) -> Option<( + PathBuf, + ImageBuffer, Vec>, + ImageBuffer, Vec>, +)> { + let cache_dir = cache_dir(); + let image_operation = load_thumbnail(buffer, cache_dir.as_deref(), &path); + + let (tx, rx) = tokio::sync::oneshot::channel(); + + if let Some(image_operation) = image_operation { + let tokio_handle = tokio::runtime::Handle::current(); + + rayon::spawn_fifo(move || { + let display_thumbnail = match image_operation { + ImageOperation::Cached(thumbnail) => thumbnail.to_rgba8(), + + ImageOperation::GenerateThumbnail { path, image } => { + let image = image.thumbnail(300, 169).to_rgba8(); + + if let Some(path) = path { + // Save thumbnail to disk without blocking. + tokio_handle.spawn_blocking({ + let image = image.clone(); + move || { + if let Err(why) = image.save(&path) { + tracing::error!(?path, ?why, "failed to save image thumbnail"); + + let _res = std::fs::remove_file(&path); + } + } + }); + } + + image + } + }; + + let mut selection_thumbnail = image::imageops::resize( + &display_thumbnail, + 158, + 105, + image::imageops::FilterType::Lanczos3, + ); + + round(&mut selection_thumbnail, [8, 8, 8, 8]); + + let _res = tx.send(Some((path, display_thumbnail, selection_thumbnail))); + }); + } else { + tx.send(None); + } + + rx.await.unwrap_or(None) +} + enum ImageOperation { GenerateThumbnail { path: Option,