From 900bb45758def252f6e992d08f616f362a0960b8 Mon Sep 17 00:00:00 2001 From: Michael Murphy Date: Fri, 23 Jun 2023 17:18:05 +0200 Subject: [PATCH] feat: implement wallpaper settings page --- Cargo.lock | 43 +- README.md | 35 +- app/Cargo.toml | 2 +- app/src/app.rs | 11 +- app/src/main.rs | 61 +- app/src/pages/desktop/wallpaper.rs | 389 ------------- app/src/pages/desktop/wallpaper/mod.rs | 622 +++++++++++++++++++++ app/src/pages/desktop/wallpaper/widgets.rs | 113 ++++ app/src/widget/mod.rs | 20 +- i18n/en/cosmic_settings.ftl | 4 +- justfile | 6 +- pages/desktop/src/wallpaper.rs | 183 +++++- 12 files changed, 1009 insertions(+), 480 deletions(-) delete mode 100644 app/src/pages/desktop/wallpaper.rs create mode 100644 app/src/pages/desktop/wallpaper/mod.rs create mode 100644 app/src/pages/desktop/wallpaper/widgets.rs diff --git a/Cargo.lock b/Cargo.lock index 4837a6a..4056632 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,6 +616,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorgrad" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5f405d474b9d05e0a093d3120e77e9bf26461b57a84b40aa2a221ac5617fb6" +dependencies = [ + "csscolorparser", +] + [[package]] name = "com-rs" version = "0.2.1" @@ -712,13 +721,15 @@ dependencies = [ [[package]] name = "cosmic-bg-config" version = "0.1.0" -source = "git+https://github.com/pop-os/cosmic-bg#9958bb035bf4a108d2fc537a6f1134e5bc21262c" +source = "git+https://github.com/pop-os/cosmic-bg#69c7b5fdaea935fff6d5564cc7bce797dd1d1408" dependencies = [ + "colorgrad", "cosmic-config", "derive_setters", "image", "ron", "serde", + "tracing", ] [[package]] @@ -2695,15 +2706,6 @@ dependencies = [ "libc", ] -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata", -] - [[package]] name = "memchr" version = "2.5.0" @@ -3638,24 +3640,9 @@ checksum = "d0ab3ca65655bb1e41f2a8c8cd662eb4fb035e67c3f78da1d61dffe89d07300f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.7.2", + "regex-syntax", ] -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.7.2" @@ -4538,14 +4525,10 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77" dependencies = [ - "matchers", "nu-ansi-term", - "once_cell", - "regex", "sharded-slab", "smallvec", "thread_local", - "tracing", "tracing-core", "tracing-log", ] diff --git a/README.md b/README.md index 54cb7df..c3c47be 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # COSMIC Settings -> Prototype of a proof of concept that is an active work in progress. +The settings application for the [COSMIC desktop environment](https://github.com/pop-os/cosmic-epoch). Developed with [libcosmic](https://github.com/pop-os/libcosmic), using the [iced](https://iced.rs/) GUI library. -The settings application for the [COSMIC desktop environment](https://github.com/pop-os/cosmic-epoch). Developed with [libcosmic](https://github.com/pop-os/libcosmic) in the [iced](https://iced.rs/) GUI library. - -## Build +## Build & Install To compile, a stable Rust compiler and [just](https://github.com/casey/just) are required. @@ -23,11 +21,20 @@ Some C libraries are also required for font support at the moment. Then it can be compiled and installed like so. ```sh -just build-release -sudo just prefix=/usr install +just +sudo just install ``` -If you are packaging for Linux distribution, you can use the `rootdir` variable to change the root path, in addition to the prefix. +## Packagers + +If packaging for a Linux distribution, vendor dependencies locally with the `vendor` rule, and build with the vendored sources using the `build-vendored` rule. + +```sh +just vendor +just build-vendored +``` + +When installing files, use the `rootdir` and `prefix` variables to change installation paths. ```sh just rootdir=debian/cosmic-settings prefix=/usr install @@ -37,6 +44,20 @@ just rootdir=debian/cosmic-settings prefix=/usr install Translation files may be found in the [i18n directory](./i18n). New translations may copy the [English (en) localization](./i18n/en) of the project and rename `en` to the desired [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes). Translations may be submitted through GitHub as an issue or pull request. Submissions by email or other means are also acceptable; with the preferred name and email to associate with the changes. +## Developers + +Run the cosmic-settings binary with `just run` so that logs will be emitted to stderr, and crashes will generate detailed backtraces. Applications shouldn't crash, so when writing code, avoid use of `unwrap()` and `expect()`. Instead, log errors with `tracing::error!()` or `tracing::warn!()`. + +This project is split across the following workspace members: + +- [app](./app/): cosmic-settings GUI frontend and binary +- [page](./page/): library for creating and handling settings pages +- [pages](./pages/): libraries for page-specific logic + +When creating a new page, UI-specific code will go directly into **app**, and page-specific logic will go into a crate under the **pages** directory. This is mainly to isolate page-specific crate dependencies and logic from the UI; so that the source code specific to the UI is easier to maintain and refactor. + +Eventually, pages may be separated into plugins, and this will help with that migration. + ## License Licensed under the [GNU Public License 3.0](https://choosealicense.com/licenses/gpl-3.0). diff --git a/app/Cargo.toml b/app/Cargo.toml index bc415d2..3c83ccb 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -27,7 +27,7 @@ downcast-rs = "1.2.0" # TODO: migrate this dependency to the pages/desktop crate. cosmic-panel-config = { workspace = true } tracing = "0.1.37" -tracing-subscriber = { version = "0.3.17", features = ["env-filter"]} +tracing-subscriber = "0.3.17" log = "0.4" env_logger = "0.10" url = "2.3.1" diff --git a/app/src/app.rs b/app/src/app.rs index 3b51fac..6fec96a 100644 --- a/app/src/app.rs +++ b/app/src/app.rs @@ -193,8 +193,8 @@ impl Application for SettingsApp { |(_, e)| match e { Ok(config) => Message::PanelConfig(config), Err((errors, config)) => { - for error in errors { - log::error!("Error loading panel config: {:?}", error); + for why in errors { + tracing::error!(?why, "panel config load error"); } Message::PanelConfig(config) } @@ -209,6 +209,7 @@ impl Application for SettingsApp { let mut ret = Command::none(); match message { Message::WindowResize(width, _height) => { + tracing::debug!(width, "new window width"); let break_point = (600.0 * self.scaling_factor) as u32; self.window_width = width; self.is_condensed = self.window_width < break_point; @@ -281,13 +282,11 @@ impl Application for SettingsApp { return self.activate_page(page); } crate::pages::Message::Panel(message) => { - if let Some(page) = self.pages.page_mut::() { - page.update(message); - } + page::update!(self.pages, message, panel::Page); } crate::pages::Message::Applet(message) => { if let Some(page) = self.pages.page_mut::() { - ret = page.update(message); + return page.update(message); } } }, diff --git a/app/src/main.rs b/app/src/main.rs index d057144..df58ea3 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -23,6 +23,7 @@ use cosmic::{ iced_sctk::settings::InitialSurface, }; use i18n_embed::DesktopLanguageRequester; +use tracing_subscriber::prelude::*; /// # Errors /// @@ -34,30 +35,12 @@ pub fn main() -> color_eyre::Result<()> { std::env::set_var("RUST_SPANTRACE", "0"); } - if std::env::var_os("RUST_LOG").is_none() { - std::env::set_var("RUST_LOG", "info"); - } - - tracing_subscriber::fmt() - .pretty() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .with_writer(std::io::stderr) - .without_time() - .with_line_number(true) - .with_file(true) - .with_target(false) - .with_thread_names(true) - .init(); - - let localizer = crate::localize::localizer(); - let requested_languages = DesktopLanguageRequester::requested_languages(); - - if let Err(why) = localizer.select(&requested_languages) { - tracing::error!(%why, "error while loading fluent localizations"); - } + init_logger(); + init_localizer(); cosmic::settings::set_default_icon_theme("Pop"); let mut settings = cosmic::settings(); + settings.default_text_size = 14.0; settings.initial_surface = InitialSurface::XdgWindow(SctkWindowSettings { title: Some(fl!("app")), size_limits: Limits::NONE.min_width(600.0).min_height(300.0), @@ -69,3 +52,39 @@ pub fn main() -> color_eyre::Result<()> { Ok(()) } + +fn init_localizer() { + let localizer = crate::localize::localizer(); + let requested_languages = DesktopLanguageRequester::requested_languages(); + + if let Err(why) = localizer.select(&requested_languages) { + tracing::error!(%why, "error while loading fluent localizations"); + } +} + +fn init_logger() { + let log_level = std::env::var("RUST_LOG") + .ok() + .and_then(|level| level.parse::().ok()) + .unwrap_or(tracing::Level::INFO); + + let log_format = tracing_subscriber::fmt::format() + .pretty() + .without_time() + .with_line_number(true) + .with_file(true) + .with_target(false) + .with_thread_names(true); + + let log_filter = tracing_subscriber::fmt::Layer::default() + .with_writer(std::io::stderr) + .event_format(log_format) + .with_filter(tracing_subscriber::filter::filter_fn(move |metadata| { + let target = metadata.target(); + metadata.level() == &tracing::Level::ERROR + || ((target.starts_with("cosmic_settings") || target.starts_with("cosmic_bg")) + && metadata.level() <= &log_level) + })); + + tracing_subscriber::registry().with(log_filter).init(); +} diff --git a/app/src/pages/desktop/wallpaper.rs b/app/src/pages/desktop/wallpaper.rs deleted file mode 100644 index ecd15de..0000000 --- a/app/src/pages/desktop/wallpaper.rs +++ /dev/null @@ -1,389 +0,0 @@ -// Copyright 2023 System76 -// SPDX-License-Identifier: GPL-3.0-only - -use std::{collections::HashMap, path::PathBuf, time::Instant}; - -use apply::Apply; -use cosmic::{ - iced::alignment::Horizontal, - iced::widget::{column, row}, - iced::Length, - iced_runtime::core::image::Handle as ImageHandle, - widget::{ - list_column, - segmented_button::{self, SingleSelectModel}, - settings, toggler, - }, - Element, -}; -use cosmic_settings_desktop::wallpaper::{self, Entry, Output, ScalingMode}; -use cosmic_settings_page::Section; -use cosmic_settings_page::{self as page, section}; -use slotmap::{DefaultKey, SecondaryMap, SlotMap}; - -#[derive(Clone, Debug)] -pub enum Message { - Fit(String), - Output(segmented_button::Entity), - RotationFrequency(String), - SameBackground(bool), - Select(DefaultKey), - Slideshow(bool), - Update((wallpaper::Config, HashMap, Context)), -} - -pub struct Page { - pub active_output: Option, - pub config: wallpaper::Config, - pub current_directory: PathBuf, - pub fit_options: Vec, - pub outputs: SingleSelectModel, - pub rotation_frequency: u64, - pub rotation_options: Vec, - pub same_background: bool, - pub selected_fit: usize, - pub selected_rotation: usize, - pub selection: Context, - pub slideshow: bool, -} - -const FIT: usize = 0; -const STRETCH: usize = 1; -const ZOOM: usize = 2; - -const MINUTES_5: usize = 0; -const MINUTES_10: usize = 1; -const MINUTES_15: usize = 2; -const MINUTES_30: usize = 3; -const HOUR_1: usize = 4; -const HOUR_2: usize = 5; - -impl Default for Page { - fn default() -> Self { - Page { - active_output: None, - config: wallpaper::Config::default(), - current_directory: PathBuf::from("/usr/share/backgrounds/pop/"), - fit_options: vec![fl!("fit-to-screen"), fl!("stretch"), fl!("zoom")], - outputs: SingleSelectModel::default(), - rotation_frequency: 300, - rotation_options: vec![ - fl!("x-minutes", number = 5), - fl!("x-minutes", number = 10), - fl!("x-minutes", number = 15), - fl!("x-minutes", number = 30), - fl!("x-hours", number = 1), - fl!("x-hours", number = 2), - ], - same_background: true, - selected_fit: 0, - selected_rotation: 0, - selection: Context::default(), - slideshow: false, - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct Context { - active: DefaultKey, - handles: SlotMap, - paths: SecondaryMap, -} - -impl Page { - /// Applies the current settings to cosmic-bg. - pub fn apply(&mut self) { - let path = if self.slideshow { - &self.current_directory - } else if let Some(path) = self.selection.paths.get(self.selection.active) { - path - } else { - return; - }; - - let output = if self.same_background { - Output::All - } else if let Some(name) = self.outputs.active_data::() { - Output::Name(name.clone()) - } else { - return; - }; - - let scaling_mode = match self.selected_fit { - FIT => ScalingMode::Fit([0.0, 0.0, 0.0]), - STRETCH => ScalingMode::Stretch, - ZOOM => ScalingMode::Zoom, - _ => return, - }; - - let entry = Entry::new(output.clone(), path.clone()) - .scaling_mode(scaling_mode) - .rotation_frequency(self.rotation_frequency); - - if output != Output::All { - self.config.backgrounds.clear(); - self.config.outputs.clear(); - } - - wallpaper::set(&mut self.config, entry); - } - - pub fn update(&mut self, message: Message) { - match message { - Message::Fit(option) => { - self.selected_fit = self - .fit_options - .iter() - .enumerate() - .find(|(_, key)| **key == option) - .map_or(0, |(indice, _)| indice); - } - - Message::Output(id) => { - self.outputs.activate(id); - if let Some(name) = self.outputs.data::(id) { - self.active_output = Some(name.clone()); - } - } - - Message::RotationFrequency(option) => { - self.selected_rotation = self - .fit_options - .iter() - .enumerate() - .find(|(_, key)| **key == option) - .map_or(0, |(indice, _)| indice); - - self.rotation_frequency = match self.selected_rotation { - MINUTES_5 => 300, - MINUTES_10 => 600, - MINUTES_15 => 900, - MINUTES_30 => 1800, - HOUR_1 => 3600, - HOUR_2 => 7200, - _ => 10800, - }; - } - - Message::SameBackground(value) => { - self.same_background = value; - } - - Message::Select(id) => { - self.selection.active = id; - } - - Message::Slideshow(value) => { - self.slideshow = value; - } - - Message::Update((config, outputs, selection)) => { - self.config = config; - self.selection = selection; - self.outputs.clear(); - - { - let mut first = None; - for (name, model) in outputs { - let entity = self - .outputs - .insert() - .text(format!("{model} ({name})")) - .data(name); - - if first.is_none() { - first = Some(entity.id()); - } - } - - if let Some(id) = first { - self.outputs.activate(id); - } - } - - if let Some(entry) = self - .config - .backgrounds - .iter() - .find(|b| b.output == Output::All) - { - self.same_background = true; - for (entity, path) in self.selection.paths.iter() { - if path == &entry.source { - self.selection.active = entity; - - match entry.scaling_mode { - ScalingMode::Fit(_) => self.selected_fit = FIT, - ScalingMode::Stretch => self.selected_fit = STRETCH, - ScalingMode::Zoom => self.selected_fit = ZOOM, - } - - self.slideshow = path.is_dir(); - - match entry.rotation_frequency { - 600 => self.selected_rotation = MINUTES_10, - 900 => self.selected_rotation = MINUTES_15, - 1800 => self.selected_rotation = MINUTES_30, - 3600 => self.selected_rotation = HOUR_1, - 7200 => self.selected_rotation = HOUR_2, - _ => self.selected_rotation = MINUTES_5, - } - - self.rotation_frequency = entry.rotation_frequency; - } - } - } - } - } - - self.apply(); - } -} - -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 load(&self, _page: page::Entity) -> Option> { - Some(Box::pin(async move { - let (config, outputs) = wallpaper::config(); - - let mut backgrounds = - wallpaper::load_each_from_path("/usr/share/backgrounds/pop/".into()); - - let mut update = Context::default(); - - let start = Instant::now(); - - while let Some((path, image)) = backgrounds.recv().await { - let handle = - ImageHandle::from_pixels(image.width(), image.height(), image.into_vec()); - - let id = update.handles.insert(handle); - update.paths.insert(id, path); - } - - tracing::info!( - "loaded wallpapers in {:?}", - Instant::now().duration_since(start) - ); - - crate::pages::Message::DesktopWallpaper(Message::Update((config, outputs, update))) - })) - } -} - -impl page::AutoBind for Page {} - -pub fn settings() -> Section { - Section::default() - .descriptions(vec![ - fl!("wallpaper", "same"), - fl!("wallpaper", "fit"), - fl!("wallpaper", "slide"), - fl!("wallpaper", "change"), - ]) - .view::(|_binder, page, section| { - let descriptions = §ion.descriptions; - - let mut image_column = Vec::with_capacity(page.selection.handles.len() / 4); - let mut image_handles = page.selection.handles.iter(); - - while let Some((id, handle)) = image_handles.next() { - let mut image_row = Vec::with_capacity(4); - - image_row.push(wallpaper_button(handle, id)); - - for (id, handle) in image_handles.by_ref().take(3) { - image_row.push(wallpaper_button(handle, id)); - } - - image_column.push(row(image_row).spacing(16).into()); - } - - let mut children = Vec::with_capacity(3); - - if let Some(image) = page.selection.handles.get(page.selection.active) { - children.push(crate::widget::display_container(image.clone(), 300.0)); - } - - children.push(if page.same_background { - cosmic::widget::text("All Displays") - .horizontal_alignment(Horizontal::Center) - .width(Length::Fill) - .apply(cosmic::iced::widget::container) - .width(Length::Fill) - .padding([0, 0, 16, 0]) - .into() - } else { - cosmic::widget::horiontal_view_switcher(&page.outputs) - .on_activate(Message::Output) - .into() - }); - - let background_fit = cosmic::iced::widget::pick_list( - &page.fit_options, - page.fit_options.get(page.selected_fit).cloned(), - Message::Fit, - ); - - children.push({ - let column = list_column() - .add(settings::item( - &descriptions[0], - toggler(None, page.same_background, Message::SameBackground), - )) - .add(settings::item(&descriptions[1], background_fit)) - .add(settings::item( - &descriptions[2], - toggler(None, page.slideshow, Message::Slideshow), - )); - - if page.slideshow { - column - .add(settings::item( - &descriptions[3], - cosmic::iced::widget::pick_list( - &page.rotation_options, - page.rotation_options.get(page.selected_rotation).cloned(), - Message::RotationFrequency, - ), - )) - .into() - } else { - column.into() - } - }); - - children.push(column(image_column).spacing(12).padding(0).into()); - - cosmic::iced::widget::column(children) - .spacing(22) - .padding(0) - .max_width(683) - .apply(Element::from) - .map(crate::pages::Message::DesktopWallpaper) - }) -} - -fn wallpaper_button(handle: &ImageHandle, id: DefaultKey) -> Element { - let image = cosmic::iced::widget::image(handle.clone()).apply(cosmic::iced::Element::from); - - cosmic::iced::widget::button(image) - .width(Length::Fixed(158.0)) - .height(Length::Fixed(105.0)) - .style(cosmic::theme::Button::Transparent) - .on_press(Message::Select(id)) - .into() -} diff --git a/app/src/pages/desktop/wallpaper/mod.rs b/app/src/pages/desktop/wallpaper/mod.rs new file mode 100644 index 0000000..f960b1b --- /dev/null +++ b/app/src/pages/desktop/wallpaper/mod.rs @@ -0,0 +1,622 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +mod widgets; + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + time::Instant, +}; + +use apply::Apply; +use cosmic::widget::{ + list_column, + segmented_button::{self, SingleSelectModel}, + settings, toggler, +}; +use cosmic::{iced::alignment::Horizontal, iced::Length, Element}; +use cosmic::{iced_core::alignment::Vertical, iced_runtime::core::image::Handle as ImageHandle}; +use cosmic_settings_desktop::wallpaper::{self, Entry, ScalingMode}; +use cosmic_settings_page::Section; +use cosmic_settings_page::{self as page, section}; +use slotmap::{DefaultKey, SecondaryMap, SlotMap}; + +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; + +const MINUTES_5: usize = 0; +const MINUTES_10: usize = 1; +const MINUTES_15: usize = 2; +const MINUTES_30: usize = 3; +const HOUR_1: usize = 4; +const HOUR_2: usize = 5; + +#[derive(Clone, Debug)] +pub enum Message { + ChangeCategory(String), + ColorSelect(wallpaper::Color), + Fit(String), + Output(segmented_button::Entity), + RotationFrequency(String), + SameBackground(bool), + Select(DefaultKey), + Slideshow(bool), + Update(Box<(wallpaper::Config, HashMap, Context)>), +} + +pub enum Category { + SystemBackgrounds, + Colors, +} + +pub struct Page { + pub active_output: Option, + pub active_category: usize, + pub categories: Vec, + pub config: wallpaper::Config, + pub current_directory: PathBuf, + pub fit_options: Vec, + pub outputs: SingleSelectModel, + pub rotation_frequency: u64, + pub rotation_options: Vec, + pub selected_fit: usize, + pub selected_rotation: usize, + pub selection: Context, +} + +impl Default for Page { + fn default() -> Self { + Page { + active_output: None, + active_category: CATEGORY_SYSTEM_WALLPAPERS, + categories: vec![fl!("system-backgrounds"), fl!("colors")], + config: wallpaper::Config::default(), + current_directory: PathBuf::from(SYSTEM_WALLPAPER_DIR), + fit_options: vec![fl!("fit-to-screen"), fl!("stretch"), fl!("zoom")], + outputs: SingleSelectModel::default(), + rotation_frequency: 300, + rotation_options: vec![ + // FIX: fluent is inserting extra unicode characters in formatting + fl!("x-minutes", number = 5) + .replace('\u{2068}', "") + .replace('\u{2069}', ""), + fl!("x-minutes", number = 10) + .replace('\u{2068}', "") + .replace('\u{2069}', ""), + fl!("x-minutes", number = 15) + .replace('\u{2068}', "") + .replace('\u{2069}', ""), + fl!("x-minutes", number = 30) + .replace('\u{2068}', "") + .replace('\u{2069}', ""), + fl!("x-hours", number = 1) + .replace('\u{2068}', "") + .replace('\u{2069}', ""), + fl!("x-hours", number = 2) + .replace('\u{2068}', "") + .replace('\u{2069}', ""), + ], + selected_fit: 0, + selected_rotation: 0, + selection: Context::default(), + } + } +} + +#[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_handles: SecondaryMap, + selection_handles: SecondaryMap, +} + +impl Page { + fn config_output(&self) -> Option { + if self.config.same_on_all { + Some(String::from("all")) + } else { + self.outputs.active_data::().cloned() + } + } + + /// Applies the current settings to cosmic-bg. + pub fn config_apply(&mut self) { + let Some(output) = self.config_output() else { + return; + }; + + if self.config.same_on_all { + self.config.outputs.clear(); + } + + let entry = match self.selection.active { + Choice::Slideshow => { + match self.config_background_entry(output.clone(), self.current_directory.clone()) { + Some(entry) => entry, + None => return, + } + } + Choice::Background(key) => { + if let Some(path) = self.selection.paths.get(key) { + match self.config_background_entry(output.clone(), path.clone()) { + Some(entry) => entry, + None => return, + } + } else { + return; + } + } + + Choice::Color(ref color) => { + Entry::new(output.clone(), wallpaper::Source::Color(color.clone())) + } + }; + + if output != "all" { + self.config.backgrounds.clear(); + self.config.outputs.clear(); + } + + wallpaper::set(&mut self.config, entry); + } + + 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]), + STRETCH => ScalingMode::Stretch, + ZOOM => ScalingMode::Zoom, + _ => return None, + }; + + Entry::new(output, wallpaper::Source::Path(path)) + .scaling_mode(scaling_mode) + .rotation_frequency(self.rotation_frequency) + .apply(Some) + } + + fn config_update( + &mut self, + config: wallpaper::Config, + displays: HashMap, + selection: Context, + ) { + self.config = config; + self.selection = selection; + self.outputs.clear(); + + let mut first = None; + for (name, model) in displays { + let entity = self + .outputs + .insert() + .text(format!("{model} ({name})")) + .data(name); + + if first.is_none() { + first = Some(entity.id()); + } + } + + if let Some(id) = first { + self.outputs.activate(id); + } + + if self.config.same_on_all || self.config.backgrounds.is_empty() { + let entry = self.config.default_background.clone(); + self.select_background_entry(&entry); + + if let Some(current) = entry_directory(&entry) { + self.current_directory = current; + } + } else if let Some(data) = self.outputs.active_data::() { + let mut backgrounds = Vec::new(); + std::mem::swap(&mut self.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; + } + + break; + } + } + + std::mem::swap(&mut self.config.backgrounds, &mut backgrounds); + } + } + + /// Changes the selection category, such as wallpaper select or color select. + fn change_category(&mut self, category: &str) { + if let Some(pos) = self.categories.iter().position(|c| c == category) { + self.active_category = pos; + match pos { + CATEGORY_SYSTEM_WALLPAPERS => { + self.select_first_background(); + } + + CATEGORY_COLOR => { + self.selection.active = Choice::Color(wallpaper::DEFAULT_COLORS[0].clone()); + } + + _ => (), + } + } + } + + /// Changes the output being configured + pub fn change_output(&mut self, entity: segmented_button::Entity) { + self.outputs.activate(entity); + if let Some(name) = self.outputs.data::(entity) { + self.active_output = Some(name.clone()); + } + } + + // Changes the slideshow background rotation frequency + pub fn change_rotation_frequency(&mut self, option: &str) { + self.selected_rotation = self + .rotation_options + .iter() + .enumerate() + .find(|(_, key)| **key == option) + .map_or(0, |(indice, _)| indice); + + self.rotation_frequency = match self.selected_rotation { + MINUTES_5 => 300, + MINUTES_10 => 600, + MINUTES_15 => 900, + MINUTES_30 => 1800, + HOUR_1 => 3600, + HOUR_2 => 7200, + _ => 10800, + }; + } + + #[allow(clippy::too_many_lines)] + pub fn update(&mut self, message: Message) { + match message { + Message::ChangeCategory(category) => self.change_category(&category), + + Message::ColorSelect(color) => { + self.selection.active = Choice::Color(color); + } + + Message::Fit(option) => { + self.selected_fit = self + .fit_options + .iter() + .enumerate() + .find(|(_, key)| **key == option) + .map_or(0, |(indice, _)| indice); + } + + Message::Output(id) => self.change_output(id), + + Message::RotationFrequency(option) => self.change_rotation_frequency(&option), + + Message::SameBackground(value) => { + self.config.same_on_all = value; + self.config.backgrounds.clear(); + } + + Message::Select(id) => { + self.selection.active = Choice::Background(id); + } + + Message::Slideshow(enable) => { + if enable { + self.selection.active = Choice::Slideshow; + } else { + self.select_first_background(); + } + } + + Message::Update(update) => self.config_update(update.0, update.1, update.2), + } + + self.config_apply(); + } + + /// Selects the given background entry. + fn select_background_entry(&mut self, entry: &wallpaper::Entry) { + match entry.source { + wallpaper::Source::Path(ref path) => { + if path.is_dir() { + self.selection.active = Choice::Slideshow; + } else if let Some(entity) = self.background_id_from_path(path) { + self.select_background(entry, entity, path.is_dir()); + } + } + + wallpaper::Source::Color(ref color) => { + self.selection.active = Choice::Color(color.clone()); + self.active_category = CATEGORY_COLOR; + } + } + } + + /// 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()); + } + } + } + } + + /// Selects the given background + fn select_background( + &mut self, + entry: &wallpaper::Entry, + entity: DefaultKey, + is_slideshow: bool, + ) { + self.selection.active = if is_slideshow { + Choice::Slideshow + } else { + Choice::Background(entity) + }; + + match entry.scaling_mode { + ScalingMode::Fit(_) => self.selected_fit = FIT, + ScalingMode::Stretch => self.selected_fit = STRETCH, + ScalingMode::Zoom => self.selected_fit = ZOOM, + } + + match entry.rotation_frequency { + 600 => self.selected_rotation = MINUTES_10, + 900 => self.selected_rotation = MINUTES_15, + 1800 => self.selected_rotation = MINUTES_30, + 3600 => self.selected_rotation = HOUR_1, + 7200 => self.selected_rotation = HOUR_2, + _ => self.selected_rotation = MINUTES_5, + } + + self.rotation_frequency = entry.rotation_frequency; + } + + /// Locate the ID of a background that's already stored in memory + fn background_id_from_path(&self, path: &Path) -> Option { + self.selection + .paths + .iter() + .find(|(_id, background)| *background == path) + .map(|(id, _)| id) + } +} + +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 load(&self, _page: page::Entity) -> Option> { + Some(Box::pin(async move { + let start = Instant::now(); + + 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); + + let display_handle = ImageHandle::from_pixels( + display_image.width(), + display_image.height(), + display_image.into_vec(), + ); + + update.display_handles.insert(id, display_handle); + + let selection_handle = ImageHandle::from_pixels( + selection_image.width(), + selection_image.height(), + selection_image.into_vec(), + ); + update.selection_handles.insert(id, selection_handle); + } + + tracing::debug!( + "loaded wallpapers in {:?}", + Instant::now().duration_since(start) + ); + + crate::pages::Message::DesktopWallpaper(Message::Update(Box::new(( + config, outputs, update, + )))) + })) + } +} + +impl page::AutoBind for Page {} + +#[allow(clippy::too_many_lines)] +pub fn settings() -> Section { + Section::default() + .descriptions(vec![ + fl!("wallpaper", "same"), + fl!("wallpaper", "fit"), + fl!("wallpaper", "slide"), + fl!("wallpaper", "change"), + ]) + .view::(|_binder, page, section| { + let descriptions = §ion.descriptions; + + let mut children = Vec::with_capacity(3); + + let mut show_slideshow_toggle = true; + let mut slideshow_enabled = false; + + let display_demo = match page.selection.active { + // Shows background options, with the slideshow toggle enabled + Choice::Slideshow => { + slideshow_enabled = true; + page.selection + .display_handles + .values() + .next() + .map(|handle| { + cosmic::iced::widget::image(handle.clone()) + .width(Length::Fixed(300.0)) + .into() + }) + } + + // Shows background options, with the slideshow toggle visible + Choice::Background(key) => page.selection.display_handles.get(key).map(|handle| { + cosmic::iced::widget::image(handle.clone()) + .width(Length::Fixed(300.0)) + .into() + }), + + // Displays color options, and hides the slideshow toggle + Choice::Color(ref color) => { + show_slideshow_toggle = false; + Some(widgets::color_image(color.clone(), 300, 169, 0.0)) + } + }; + + if let Some(element) = display_demo { + children.push(crate::widget::display_container(element)); + } + + children.push(if page.config.same_on_all { + cosmic::widget::text(fl!("all-displays")) + .font(cosmic::font::FONT_SEMIBOLD) + .horizontal_alignment(Horizontal::Center) + .vertical_alignment(Vertical::Center) + .width(Length::Fill) + .height(Length::Fill) + .apply(cosmic::iced::widget::container) + .width(Length::Fill) + .height(Length::Fixed(32.0)) + .into() + } else { + cosmic::widget::horizontal_segmented_selection(&page.outputs) + .on_activate(Message::Output) + .into() + }); + + let background_fit = cosmic::iced::widget::pick_list( + &page.fit_options, + page.fit_options.get(page.selected_fit).cloned(), + Message::Fit, + ); + + children.push({ + let mut column = list_column() + .add(settings::item( + &descriptions[0], + toggler(None, page.config.same_on_all, Message::SameBackground), + )) + .add(settings::item(&descriptions[1], background_fit)); + + if show_slideshow_toggle { + column = column.add(settings::item( + &descriptions[2], + toggler(None, slideshow_enabled, Message::Slideshow), + )); + } + + // The rotation frequency pick list should only be shown when the slideshow is enabled. + if slideshow_enabled { + column + .add(settings::item( + &descriptions[3], + cosmic::iced::widget::pick_list( + &page.rotation_options, + page.rotation_options.get(page.selected_rotation).cloned(), + Message::RotationFrequency, + ), + )) + .into() + } else { + column.into() + } + }); + + let category_selection = cosmic::iced::widget::pick_list( + &page.categories, + Some(page.categories[page.active_category].clone()), + Message::ChangeCategory, + ); + + children.push(category_selection.into()); + + match page.active_category { + // Displays system wallpapers that are available to select from + CATEGORY_SYSTEM_WALLPAPERS => { + children.push(widgets::wallpaper_select_options(page)); + } + + // Displays colors and gradients that are available to select from + CATEGORY_COLOR => { + children.push(widgets::color_select_options()); + } + + _ => (), + } + + cosmic::iced::widget::column(children) + .spacing(22) + .padding(0) + .max_width(683) + .apply(Element::from) + .map(crate::pages::Message::DesktopWallpaper) + }) +} + +/// Sets the current wallpaper directory. +fn entry_directory(entry: &wallpaper::Entry) -> Option { + Some(match entry.source { + wallpaper::Source::Path(ref path) => { + if path.is_dir() { + path.clone() + } else if let Some(path) = path.parent() { + path.to_path_buf() + } else { + return None; + } + } + + wallpaper::Source::Color(_) => PathBuf::from(SYSTEM_WALLPAPER_DIR), + }) +} diff --git a/app/src/pages/desktop/wallpaper/widgets.rs b/app/src/pages/desktop/wallpaper/widgets.rs new file mode 100644 index 0000000..19c667e --- /dev/null +++ b/app/src/pages/desktop/wallpaper/widgets.rs @@ -0,0 +1,113 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use super::Message; +use apply::Apply; +use cosmic::iced_core::{self, gradient::Linear, Background, BorderRadius, Color, Degrees, Length}; +use cosmic::iced_runtime::core::image::Handle as ImageHandle; +use cosmic::{ + iced, + iced_widget::{column, row}, + Element, +}; +use cosmic_settings_desktop::wallpaper; +use slotmap::DefaultKey; + +/// A button for selecting a color or gradient. +pub fn color_button(color: wallpaper::Color) -> Element<'static, Message> { + iced::widget::button(color_image(color.clone(), 70, 70, 8.0)) + .width(Length::Fixed(71.0)) + .height(Length::Fixed(71.0)) + .style(cosmic::theme::Button::Transparent) + .on_press(Message::ColorSelect(color)) + .into() +} + +/// A sized container that's filled with a color or gradient. +pub fn color_image( + color: wallpaper::Color, + width: u16, + height: u16, + border_radius: f32, +) -> Element<'static, Message> { + iced::widget::container(iced::widget::space::Space::new(width, height)) + .style(cosmic::theme::Container::custom(move |_theme| { + iced::widget::container::Appearance { + text_color: None, + background: Some(match &color { + wallpaper::Color::Single([r, g, b]) => { + Background::Color(Color::from_rgb(*r, *g, *b)) + } + + wallpaper::Color::Gradient(wallpaper::Gradient { colors, radius }) => { + let stop_increment = 1.0 / (colors.len() - 1) as f32; + let mut stop = 0.0; + + let mut linear = Linear::new(Degrees(*radius)); + + for &[r, g, b] in &**colors { + linear = linear.add_stop(stop, iced::Color::from_rgb(r, g, b)); + stop += stop_increment; + } + + Background::Gradient(iced_core::Gradient::Linear(linear)) + } + }), + border_radius: BorderRadius::from(border_radius), + border_width: 0.0, + border_color: Color::TRANSPARENT, + } + })) + .into() +} + +/// Color selection list +pub fn color_select_options() -> Element<'static, Message> { + let mut color_column = Vec::with_capacity(wallpaper::DEFAULT_COLORS.len() / 8); + let mut colors = wallpaper::DEFAULT_COLORS.iter(); + + while let Some(color) = colors.next() { + let mut color_row = Vec::with_capacity(8); + + color_row.push(color_button(color.clone())); + + for color in colors.by_ref().take(7) { + color_row.push(color_button(color.clone())); + } + + color_column.push(row(color_row).spacing(16).into()); + } + + column(color_column).spacing(12).padding(0).into() +} + +/// Background selection list +pub fn wallpaper_select_options(page: &super::Page) -> Element { + let mut image_column = Vec::with_capacity(page.selection.selection_handles.len() / 4); + let mut image_handles = page.selection.selection_handles.iter(); + + while let Some((id, handle)) = image_handles.next() { + let mut image_row = Vec::with_capacity(4); + + image_row.push(wallpaper_button(handle, id)); + + for (id, handle) in image_handles.by_ref().take(3) { + image_row.push(wallpaper_button(handle, id)); + } + + image_column.push(row(image_row).spacing(16).into()); + } + + column(image_column).spacing(12).padding(0).into() +} + +fn wallpaper_button(handle: &ImageHandle, id: DefaultKey) -> Element { + let image = iced::widget::image(handle.clone()).apply(iced::Element::from); + + iced::widget::button(image) + .width(Length::Fixed(158.0)) + .height(Length::Fixed(105.0)) + .style(cosmic::theme::Button::Transparent) + .on_press(Message::Select(id)) + .into() +} diff --git a/app/src/widget/mod.rs b/app/src/widget/mod.rs index ca59bbf..d8c77ef 100644 --- a/app/src/widget/mod.rs +++ b/app/src/widget/mod.rs @@ -2,15 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-only use apply::Apply; -use cosmic::widget::{divider, icon, list, settings, text}; -use cosmic::{ - iced::{ - self, - widget::{button, column, container, horizontal_space, row, vertical_space, Button}, - Length, - }, - iced_widget::core::image, +use cosmic::iced::{ + self, + widget::{button, column, container, horizontal_space, row, vertical_space, Button}, + Length, }; +use cosmic::widget::{divider, icon, list, settings, text}; use cosmic::{theme, Element}; use cosmic_settings_page as page; @@ -136,13 +133,10 @@ pub fn unimplemented_page() -> Element<'static, Message> { } #[must_use] -pub fn display_container<'a, Message: 'a>( - image: image::Handle, - width: f32, -) -> cosmic::Element<'a, Message> { +pub fn display_container<'a, Message: 'a>(widget: Element<'a, Message>) -> Element<'a, Message> { row!( horizontal_space(Length::Fill), - container(cosmic::iced::widget::image(image).width(Length::Fixed(width))) + container(widget) .padding(4) .style(crate::theme::display_container()), horizontal_space(Length::Fill), diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 5645029..99f5eef 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -104,12 +104,14 @@ wallpaper = Wallpaper .slide = Slideshow .change = Change image every +all-displays = All Displays +colors = Colors fit-to-screen = Fit to Screen stretch = Stretch +system-backgrounds = System backgrounds zoom = Zoom x-minutes = { $number } minutes - x-hours = { $number -> [1] 1 hour *[other] { $number } hours diff --git a/justfile b/justfile index 9275690..aa93c81 100644 --- a/justfile +++ b/justfile @@ -29,9 +29,7 @@ desktop-src := 'resources' / desktop desktop-dest := clean(rootdir / prefix) / 'share' / 'applications' / desktop [private] -help: - echo $RUSTFLAGS - @just -l +default: build-release # Remove Cargo build artifacts clean: @@ -74,7 +72,7 @@ install: (install-bin bin-src bin-dest) (install-file desktop-src desktop-dest) # Run the application for testing purposes run *args: - env RUST_BACKTRACE=full cargo run --release {{args}} + env RUST_LOG=debug RUST_BACKTRACE=full cargo run --release {{args}} # Run `cargo test` test: diff --git a/pages/desktop/src/wallpaper.rs b/pages/desktop/src/wallpaper.rs index 0fef8f6..dfa455a 100644 --- a/pages/desktop/src/wallpaper.rs +++ b/pages/desktop/src/wallpaper.rs @@ -1,6 +1,8 @@ -pub use cosmic_bg_config::{Config, Entry, Output, ScalingMode}; +pub use cosmic_bg_config::{Color, Config, Entry, Gradient, ScalingMode, Source}; + use image::RgbaImage; use std::{ + borrow::Cow, collections::{hash_map::DefaultHasher, HashMap}, fs::DirEntry, hash::{Hash, Hasher}, @@ -9,6 +11,39 @@ use std::{ }; use tokio::sync::mpsc::{self, Receiver}; +pub const DEFAULT_COLORS: &[Color] = &[ + Color::Single([0.580, 0.922, 0.922]), + Color::Single([0.000, 0.286, 0.427]), + Color::Single([1.000, 0.678, 0.000]), + Color::Single([0.282, 0.725, 0.78]), + Color::Single([0.333, 0.278, 0.259]), + Color::Single([0.969, 0.878, 0.384]), + Color::Single([0.063, 0.165, 0.298]), + Color::Single([1.000, 0.843, 0.631]), + Color::Single([0.976, 0.227, 0.514]), + Color::Single([1.000, 0.612, 0.867]), + Color::Single([0.812, 0.490, 1.000]), + Color::Single([0.835, 0.549, 1.000]), + Color::Single([0.243, 0.533, 1.000]), + Color::Single([0.584, 0.769, 0.988]), + Color::Gradient(Gradient { + colors: Cow::Borrowed(&[[1.000, 0.678, 0.000], [0.282, 0.725, 0.78]]), + radius: 270.0, + }), + Color::Gradient(Gradient { + colors: Cow::Borrowed(&[[1.000, 0.843, 0.631], [0.58, 0.922, 0.922]]), + radius: 270.0, + }), + Color::Gradient(Gradient { + colors: Cow::Borrowed(&[[1.000, 0.612, 0.867], [0.976, 0.29, 0.514]]), + radius: 270.0, + }), + Color::Gradient(Gradient { + colors: Cow::Borrowed(&[[0.584, 0.769, 0.988], [0.063, 0.165, 0.298]]), + radius: 270.0, + }), +]; + pub fn config() -> (Config, HashMap) { let mut displays = HashMap::new(); @@ -36,11 +71,13 @@ pub fn config() -> (Config, HashMap) { pub fn set(config: &mut Config, entry: Entry) { if let Ok(context) = Config::helper() { tracing::info!( - "setting wallpaper for {} to {}", - entry.output.to_string(), - entry.source.display() + output = entry.output.to_string(), + source = ?entry.source, + "setting wallpaper", ); + let _res = Config::set_same_on_all(&context, config.same_on_all); + if let Err(why) = config.set_entry(&context, entry) { tracing::error!(?why, "failed to set background"); } @@ -59,7 +96,7 @@ pub fn cache_dir() -> Option { /// Loads wallpapers in parallel by spawning tasks with a rayon thread pool. #[must_use] -pub fn load_each_from_path(path: PathBuf) -> Receiver<(PathBuf, RgbaImage)> { +pub fn load_each_from_path(path: PathBuf) -> Receiver<(PathBuf, RgbaImage, RgbaImage)> { let cache_dir = Arc::new(cache_dir()); let (tx, rx) = mpsc::channel(1); @@ -82,10 +119,23 @@ pub fn load_each_from_path(path: PathBuf) -> Receiver<(PathBuf, RgbaImage)> { let tx = tx.clone(); let cache_dir = cache_dir.clone(); rayon::spawn_fifo(move || { - let thumbnail = + let display_thumbnail = load_thumbnail(cache_dir.as_deref(), &path, &entry, 300, 169); - if let Some(image) = thumbnail { - let _res = tx.blocking_send((path, image)); + + if let Some(display_thumbnail) = display_thumbnail { + 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, + )); } }); } @@ -141,3 +191,120 @@ pub fn load_thumbnail( image.into_rgba8() }) } + +// https://users.rust-lang.org/t/how-to-trim-image-to-circle-image-without-jaggy/70374/2 +fn round(img: &mut image::ImageBuffer, Vec>, radius: [u32; 4]) { + let (width, height) = img.dimensions(); + assert!(radius[0] + radius[1] <= width); + assert!(radius[3] + radius[2] <= width); + assert!(radius[0] + radius[3] <= height); + assert!(radius[1] + radius[2] <= height); + + // top left + border_radius(img, radius[0], |x, y| (x - 1, y - 1)); + // top right + border_radius(img, radius[1], |x, y| (width - x, y - 1)); + // bottom right + border_radius(img, radius[2], |x, y| (width - x, height - y)); + // bottom left + border_radius(img, radius[3], |x, y| (x - 1, height - y)); +} + +fn border_radius( + img: &mut image::ImageBuffer, Vec>, + r: u32, + coordinates: impl Fn(u32, u32) -> (u32, u32), +) { + if r == 0 { + return; + } + let r0 = r; + + // 16x antialiasing: 16x16 grid creates 256 possible shades, great for u8! + let r = 16 * r; + + let mut x = 0; + let mut y = r - 1; + let mut p: i32 = 2 - r as i32; + + // ... + + let mut alpha: u16 = 0; + let mut skip_draw = true; + + let draw = |img: &mut image::ImageBuffer, Vec>, alpha, x, y| { + debug_assert!((1..=256).contains(&alpha)); + let pixel_alpha = &mut img[coordinates(r0 - x, r0 - y)].0[3]; + *pixel_alpha = ((alpha * *pixel_alpha as u16 + 128) / 256) as u8; + }; + + 'l: loop { + // (comments for bottom_right case:) + // remove contents below current position + { + let i = x / 16; + for j in y / 16 + 1..r0 { + img[coordinates(r0 - i, r0 - j)].0[3] = 0; + } + } + // remove contents right of current position mirrored + { + let j = x / 16; + for i in y / 16 + 1..r0 { + img[coordinates(r0 - i, r0 - j)].0[3] = 0; + } + } + + // draw when moving to next pixel in x-direction + if !skip_draw { + draw(img, alpha, x / 16 - 1, y / 16); + draw(img, alpha, y / 16, x / 16 - 1); + alpha = 0; + } + + for _ in 0..16 { + skip_draw = false; + + if x >= y { + break 'l; + } + + alpha += y as u16 % 16 + 1; + if p < 0 { + x += 1; + p += (2 * x + 2) as i32; + } else { + // draw when moving to next pixel in y-direction + if y % 16 == 0 { + draw(img, alpha, x / 16, y / 16); + draw(img, alpha, y / 16, x / 16); + skip_draw = true; + alpha = (x + 1) as u16 % 16 * 16; + } + + x += 1; + p -= (2 * (y - x) + 2) as i32; + y -= 1; + } + } + } + + // one corner pixel left + if x / 16 == y / 16 { + // column under current position possibly not yet accounted + if x == y { + alpha += y as u16 % 16 + 1; + } + let s = y as u16 % 16 + 1; + let alpha = 2 * alpha - s * s; + draw(img, alpha, x / 16, y / 16); + } + + // remove remaining square of content in the corner + let range = y / 16 + 1..r0; + for i in range.clone() { + for j in range.clone() { + img[coordinates(r0 - i, r0 - j)].0[3] = 0; + } + } +}