feat(wallpaper): custom background images and folders

This commit is contained in:
Michael Aaron Murphy 2023-12-07 14:08:50 +01:00 committed by Michael Murphy
parent f3e929e769
commit daa730682a
10 changed files with 928 additions and 250 deletions

5
Cargo.lock generated
View file

@ -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",
]

View file

@ -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"

View file

@ -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<crate::pages::Message>,
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<url::Url>),
}
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::<desktop::wallpaper::Page>() {
return page.update(message).map(cosmic::app::Message::App);
}
}
crate::pages::Message::Input(message) => {
if let Some(page) = self.pages.page_mut::<input::Page>() {
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::<applets_inner::Page>() {
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::<dock::applets::Page>() {
return page.update(message).map(cosmic::app::Message::App);
}
}
crate::pages::Message::Appearance(message) => {
if let Some(page) = self.pages.page_mut::<appearance::Page>() {
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()

View file

@ -0,0 +1,194 @@
// Copyright 2023 System76 <info@system76.com>
// 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<cosmic_config::Config>,
current_folder: Option<PathBuf>,
custom_colors: Vec<wallpaper::Color>,
custom_images: Vec<PathBuf>,
recent_folders: VecDeque<PathBuf>,
}
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::<PathBuf>(CURRENT_FOLDER)) {
config.current_folder = Some(path);
}
if let Ok(colors) = context.get::<Vec<wallpaper::Color>>(CUSTOM_COLORS) {
config.custom_colors = colors;
}
if let Ok(images) = context.get::<Vec<PathBuf>>(CUSTOM_IMAGES) {
config.custom_images = images;
}
if let Ok(folders) = dbg!(context.get::<VecDeque<PathBuf>>(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<PathBuf> {
&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<V: serde::Serialize>(
&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)
}
}

View file

@ -1,33 +1,36 @@
// Copyright 2023 System76 <info@system76.com>
// 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<Rgba<u8>, Vec<u8>>;
#[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<Arc<(PathBuf, Image, Image)>>),
ImageAddDialog,
ImageRemove(DefaultKey),
Output(segmented_button::Entity),
RotationFrequency(usize),
SameBackground(bool),
Select(DefaultKey),
Slideshow(bool),
Update(Box<(wallpaper::Config, HashMap<String, String>, Context)>),
Init(Box<(wallpaper::Config, HashMap<String, String>, Context)>),
}
#[derive(Clone, Debug, PartialEq)]
pub enum Category {
SystemBackgrounds,
Backgrounds,
Colors,
RecentFolder(usize),
}
pub struct Page {
pub active_output: Option<String>,
pub active_category: usize,
pub background_service_config: wallpaper::Config,
pub cached_display_handle: Option<ImageHandle>,
pub categories: Vec<String>,
pub config: wallpaper::Config,
pub current_directory: PathBuf,
pub categories: dropdown::multi::Model<String, Category>,
pub config: Config,
pub fit_options: Vec<String>,
pub outputs: SingleSelectModel,
pub recent_folders: Vec<(PathBuf, String)>,
pub rotation_frequency: u64,
pub rotation_options: Vec<String>,
pub selected_fit: usize,
@ -76,17 +90,93 @@ pub struct Page {
pub selection: Context,
}
impl page::Page<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
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<url::Url>) -> Command<crate::pages::Message> {
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<page::Task<crate::pages::Message>> {
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<crate::pages::Message> 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<DefaultKey, PathBuf>,
display_images: SecondaryMap<DefaultKey, image::RgbaImage>,
selection_handles: SecondaryMap<DefaultKey, ImageHandle>,
}
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<String> {
if self.config.same_on_all {
if self.background_service_config.same_on_all {
Some(String::from("all"))
} else {
self.outputs.active_data::<String>().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<Entry> {
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<String, String>,
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::<String>() {
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<crate::app::Message> {
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<crate::app::Message> {
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<crate::pages::Message> for Page {
fn content(
&self,
sections: &mut SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
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<page::Task<crate::pages::Message>> {
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<crate::pages::Message> for Page {}
#[derive(Clone, Debug, Default)]
pub struct Context {
active: Choice,
custom_images: Vec<DefaultKey>,
custom_colors: Vec<wallpaper::Color>,
paths: SlotMap<DefaultKey, PathBuf>,
is_custom: SecondaryMap<DefaultKey, bool>,
display_images: SecondaryMap<DefaultKey, image::RgbaImage>,
selection_handles: SecondaryMap<DefaultKey, ImageHandle>,
}
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<PathBuf> {
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<crate::pages::Message> {
},
));
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<crate::pages::Message> {
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<crate::pages::Message> {
}
});
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<crate::pages::Message> {
}
// 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<crate::pages::Message> {
));
}
_ => (),
None => (),
}
cosmic::widget::column::with_children(children)
@ -675,7 +970,7 @@ pub fn settings() -> Section<crate::pages::Message> {
}
/// Sets the current wallpaper directory.
fn entry_directory(entry: &wallpaper::Entry) -> Option<PathBuf> {
fn entry_directory(current_folder: &Path, entry: &wallpaper::Entry) -> Option<PathBuf> {
Some(match entry.source {
wallpaper::Source::Path(ref path) => {
if path.is_dir() {
@ -687,6 +982,6 @@ fn entry_directory(entry: &wallpaper::Entry) -> Option<PathBuf> {
}
}
wallpaper::Source::Color(_) => PathBuf::from(SYSTEM_WALLPAPER_DIR),
wallpaper::Source::Color(_) => PathBuf::from(current_folder),
})
}

View file

@ -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<Message> {
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<Message>>) -> Element<Message> {
.into()
}
fn wallpaper_button(handle: &ImageHandle, id: DefaultKey, selected: bool) -> Element<Message> {
fn wallpaper_button(
handle: &ImageHandle,
id: DefaultKey,
removable: bool,
selected: bool,
) -> Element<Message> {
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()
}

View file

@ -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

View file

@ -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"

View file

@ -44,6 +44,11 @@ pub trait Page<Message: 'static>: Downcast {
None
}
/// Response from a file chooser dialog request.
fn file_chooser(&mut self, _selected: Vec<url::Url>) -> Command<Message> {
Command::none()
}
#[must_use]
#[allow(unused)]
fn load(&self, page: crate::Entity) -> Option<crate::Task<Message>> {

View file

@ -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<u8>,
path: PathBuf,
) -> Option<(
PathBuf,
ImageBuffer<Rgba<u8>, Vec<u8>>,
ImageBuffer<Rgba<u8>, Vec<u8>>,
)> {
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<PathBuf>,