diff --git a/cosmic-settings/src/pages/desktop/appearance.rs b/cosmic-settings/src/pages/desktop/appearance.rs index 60129a6..34e2942 100644 --- a/cosmic-settings/src/pages/desktop/appearance.rs +++ b/cosmic-settings/src/pages/desktop/appearance.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: GPL-3.0-only use std::borrow::Cow; -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; +use std::collections::BTreeMap; +use std::path::PathBuf; use std::sync::Arc; use ashpd::desktop::file_chooser::{FileFilter, SelectedFiles}; @@ -13,13 +13,12 @@ use cosmic::cosmic_theme::palette::{FromColor, Hsv, Srgb, Srgba}; use cosmic::cosmic_theme::{ CornerRadii, Theme, ThemeBuilder, ThemeMode, DARK_THEME_BUILDER_ID, LIGHT_THEME_BUILDER_ID, }; -use cosmic::iced_core::{alignment, Color, Length}; +use cosmic::iced_core::{alignment, Background, Color, Length}; use cosmic::iced_widget::scrollable; use cosmic::prelude::CollectionWidget; -use cosmic::widget::dropdown; -use cosmic::widget::icon::{from_name, icon}; +use cosmic::widget::icon::{self, from_name, icon}; use cosmic::widget::{ - button, color_picker::ColorPickerUpdate, container, horizontal_space, row, settings, + button, color_picker::ColorPickerUpdate, container, flex_row, horizontal_space, row, settings, spin_button, text, ColorPickerModel, }; use cosmic::Apply; @@ -35,7 +34,13 @@ use crate::app; use super::wallpaper::widgets::color_image; +const ICON_PREV_N: usize = 6; +const ICON_PREV_ROW: usize = 3; +const ICON_TRY_SIZES: [u16; 3] = [32, 48, 64]; +const ICON_THUMB_SIZE: u16 = 32; +const ICON_NAME_TRUNC: usize = 20; type IconThemes = Vec; +type IconHandles = Vec<[icon::Handle; ICON_PREV_N]>; crate::cache_dynamic_lazy! { static HEX: String = fl!("hex"); @@ -52,6 +57,7 @@ enum ContextView { ContainerBackground, ControlComponent, CustomAccent, + Experimental, InterfaceText, } @@ -68,7 +74,8 @@ pub struct Page { roundness: Roundness, icon_theme_active: Option, - icon_themes: Vec, + icon_themes: IconThemes, + icon_handles: IconHandles, theme_mode: ThemeMode, theme_mode_config: Option, @@ -190,6 +197,7 @@ impl no_custom_window_hint: theme_builder.accent.is_some(), icon_theme_active: None, icon_themes: Vec::new(), + icon_handles: Vec::new(), theme_mode_config, theme_builder_config, theme_mode, @@ -267,7 +275,8 @@ pub enum Message { ControlComponent(ColorPickerUpdate), CustomAccent(ColorPickerUpdate), DarkMode(bool), - Entered(IconThemes), + Entered((IconThemes, IconHandles)), + ExperimentalContextDrawer, ExportError, ExportFile(Arc), ExportSuccess, @@ -439,6 +448,49 @@ impl Page { .map(crate::pages::Message::Appearance) } + fn experimental_context_view(&self) -> Element<'_, crate::pages::Message> { + let active = self.icon_theme_active; + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); + cosmic::iced::widget::column![ + // Export theme choice + settings::view_section("").add( + settings::item::builder(fl!("enable-export")) + .description(fl!("enable-export", "desc")) + .toggler(self.tk.apply_theme_global, Message::ApplyThemeGlobal) + ), + // Icon theme previews + // cosmic::iced::widget::column![text(&*ICON_THEME), text(&*ICON_THEME_DESC).size(10)] + // .spacing(2), + cosmic::widget::column::with_children(vec![ + text::heading(&*ICON_THEME).into(), + scrollable( + flex_row( + self.icon_themes + .iter() + .zip(self.icon_handles.iter()) + .enumerate() + .map(|(i, (theme, handles))| { + let selected = active.map(|j| i == j).unwrap_or_default(); + icon_theme_button(theme, handles, i, selected) + }) + .collect(), + ) + .row_spacing(theme.space_xs()) + .column_spacing(theme.space_xxxs()) + ) + .into() + ]) + .spacing(theme.space_xxs()) + ] + // .padding(theme.space_s()) + .spacing(theme.space_m()) + // .align_items(cosmic::iced_core::Alignment::Center) + .width(Length::Fill) + .apply(Element::from) + .map(crate::pages::Message::Appearance) + } + #[allow(clippy::too_many_lines)] pub fn update(&mut self, message: Message) -> Command { self.theme_builder_needs_update = false; @@ -572,7 +624,7 @@ impl Page { self.theme_builder_needs_update = true; Command::none() } - Message::Entered(icon_themes) => { + Message::Entered((icon_themes, icon_handles)) => { *self = Self::default(); // Set the icon themes, and define the active icon theme. @@ -581,6 +633,7 @@ impl Page { .icon_themes .iter() .position(|theme| theme == &self.tk.icon_theme); + self.icon_handles = icon_handles; Command::none() } Message::Left => Command::perform(async {}, |()| { @@ -832,6 +885,10 @@ impl Page { } Command::none() } + Message::ExperimentalContextDrawer => { + self.context_view = Some(ContextView::Experimental); + cosmic::command::message(crate::app::Message::OpenContextDrawer("".into())) + } Message::Daytime(day_time) => { self.day_time = day_time; Command::none() @@ -941,6 +998,7 @@ impl page::Page for Page { sections.insert(mode_and_colors()), sections.insert(style()), sections.insert(window_management()), + sections.insert(experimental()), sections.insert(reset_button()), ]) } @@ -1019,6 +1077,8 @@ impl page::Page for Page { |this| &this.custom_accent, ), + ContextView::Experimental => self.experimental_context_view(), + ContextView::InterfaceText => self.color_picker_context_view( None, RESET_TO_DEFAULT.as_str().into(), @@ -1301,10 +1361,6 @@ pub fn style() -> Section { fl!("style", "square").into(), fl!("frosted").into(), fl!("frosted", "desc").into(), - fl!("enable-export").into(), - fl!("enable-export", "desc").into(), - ICON_THEME.as_str().into(), - ICON_THEME_DESC.as_str().into(), ]) .view::(|_binder, page, section| { let descriptions = §ion.descriptions; @@ -1393,20 +1449,6 @@ pub fn style() -> Section { .description(&*descriptions[4]) .toggler(page.theme_builder.is_frosted, Message::Frosted), ) - .add( - settings::item::builder(&*descriptions[5]) - .description(&*descriptions[6]) - .toggler(page.tk.apply_theme_global, Message::ApplyThemeGlobal), - ) - .add( - settings::item::builder(&*ICON_THEME) - .description(&*ICON_THEME_DESC) - .control(dropdown( - &page.icon_themes, - page.icon_theme_active, - Message::IconTheme, - )), - ) .apply(Element::from) .map(crate::pages::Message::Appearance) }) @@ -1441,6 +1483,23 @@ pub fn window_management() -> Section { }) } +pub fn experimental() -> Section { + Section::default() + .descriptions(vec![fl!("experimental-settings").into()]) + .view::(|_binder, _page, section| { + let descriptions = &*section.descriptions; + settings::view_section("") + .add( + settings::item::builder(&*descriptions[0]).control( + button::icon(from_name("go-next-symbolic")) + .on_press(Message::ExperimentalContextDrawer), + ), + ) + .apply(Element::from) + .map(crate::pages::Message::Appearance) + }) +} + #[allow(clippy::too_many_lines)] pub fn reset_button() -> Section { Section::default() @@ -1486,7 +1545,8 @@ pub fn color_button<'a, Message: 'a + Clone>( /// Find all icon themes available on the system. async fn fetch_icon_themes() -> Message { - let mut icon_themes = BTreeSet::new(); + let mut icon_themes = BTreeMap::new(); + let mut theme_paths: BTreeMap = BTreeMap::new(); let mut buffer = String::new(); @@ -1533,6 +1593,7 @@ async fn fetch_icon_themes() -> Message { buffer.clear(); let mut name = None; + let mut valid_dirs = Vec::new(); let mut line_reader = tokio::io::BufReader::new(file); while let Ok(read) = line_reader.read_line(&mut buffer).await { @@ -1550,16 +1611,46 @@ async fn fetch_icon_themes() -> Message { } } + if valid_dirs.is_empty() { + if let Some(value) = buffer.strip_prefix("Inherits=") { + valid_dirs.extend(value.trim().split(',').map(|fallback| { + if let Some(path) = theme_paths.get(fallback) { + path.iter() + .last() + .and_then(|os| os.to_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| fallback.to_owned()) + } else { + fallback.to_owned() + } + })); + } + } + buffer.clear(); } if let Some(name) = name { - icon_themes.insert(name); + // Name of the directory theme was found in (e.g. Pop for Pop) + valid_dirs.push( + path.iter() + .last() + .and_then(|os| os.to_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| name.clone()), + ); + theme_paths.entry(name.clone()).or_insert(path); + + let theme = name.clone(); + // `icon::from_name` may perform blocking I/O + if let Ok(handles) = + tokio::task::spawn_blocking(|| preview_handles(theme, valid_dirs)).await + { + icon_themes.insert(name, handles); + } } } } - Message::Entered(icon_themes.into_iter().collect()) + Message::Entered(icon_themes.into_iter().unzip()) } /// Set the preferred icon theme for GNOME/GTK applications. @@ -1574,3 +1665,179 @@ async fn set_gnome_icon_theme(theme: String) { .status() .await; } + +/// Generate [icon::Handle]s to use for icon theme previews. +fn preview_handles(theme: String, inherits: Vec) -> [icon::Handle; ICON_PREV_N] { + // Cache current default and set icon theme as a temporary default + let default = cosmic::icon_theme::default(); + cosmic::icon_theme::set_default(theme); + + // Evaluate handles with the temporary theme + let handles = [ + icon_handle("folder", "folder-symbolic", &inherits), + icon_handle("user-home", "user-home-symbolic", &inherits), + icon_handle( + "preferences-system", + "preferences-system-symbolic", + &inherits, + ), + icon_handle("image-x-generic", "images-x-generic-symbolic", &inherits), + icon_handle("audio-x-generic", "audio-x-generic-symbolic", &inherits), + icon_handle("video-x-generic", "video-x-generic-symbolic", &inherits), + ]; + + // Reset default icon theme. + cosmic::icon_theme::set_default(default); + handles +} + +/// Evaluate an icon handle for a specific theme. +/// +/// `alternate` is a fallback icon name such as a symbolic variant. +/// +/// `valid_dirs` should be a slice of directories from which we consider an icon to be valid. Valid +/// directories would usually be inherited themes as well as the actual theme's location. +fn icon_handle(icon_name: &str, alternate: &str, valid_dirs: &[String]) -> icon::Handle { + ICON_TRY_SIZES + .iter() + .zip(std::iter::repeat(icon_name).take(ICON_TRY_SIZES.len())) + // Try fallback icon name after the default + .chain( + ICON_TRY_SIZES + .iter() + .zip(std::iter::repeat(alternate)) + .take(ICON_TRY_SIZES.len()), + ) + .find_map(|(&size, name)| { + icon::from_name(name) + // Set the size on the handle to evaluate the correct icon + .size(size) + // Get the path to the icon for the currently set theme. + // Without the exact path, the handles will all resolve to icons from the same theme in + // [`icon_theme_button`] rather than the icons for each different theme + .path() + // `libcosmic` should always return a path if the default theme is installed + // The returned path has to be verified as an icon from the set theme or an + // inherited theme + .and_then(|path| { + let mut theme_dir = &*path; + while let Some(parent) = theme_dir.parent() { + if parent.ends_with("icons") { + break; + } + theme_dir = parent; + } + + if let Some(dir_name) = + theme_dir.iter().last().and_then(std::ffi::OsStr::to_str) + { + valid_dirs + .iter() + .any(|valid| dir_name == valid) + .then(|| icon::from_path(path)) + } else { + None + } + }) + }) + // Fallback icon handle + .unwrap_or_else(|| icon::from_name(icon_name).size(ICON_THUMB_SIZE).handle()) +} + +/// Button with a preview of the icon theme. +fn icon_theme_button( + name: &str, + handles: &[icon::Handle], + id: usize, + selected: bool, +) -> Element<'static, Message> { + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); + let background = Background::Color(theme.palette.neutral_4.into()); + + cosmic::widget::column() + .push( + cosmic::widget::button::custom_image_button( + cosmic::widget::column::with_children(vec![ + cosmic::widget::row() + .extend( + handles + .iter() + .take(ICON_PREV_ROW) + .cloned() + // TODO: Maybe allow choosable sizes/zooming + .map(|handle| handle.icon().size(ICON_THUMB_SIZE)), + ) + .spacing(theme.space_xxs()) + .into(), + cosmic::widget::row() + .extend( + handles + .iter() + .skip(ICON_PREV_ROW) + .cloned() + // TODO: Maybe allow choosable sizes/zooming + .map(|handle| handle.icon().size(ICON_THUMB_SIZE)), + ) + .spacing(theme.space_xxs()) + .into(), + ]) + .spacing(theme.space_xs()), + None, + ) + .on_press(Message::IconTheme(id)) + .selected(selected) + .padding(theme.space_xxs()) + // Image button's style mostly works, but it needs a background to fit the design + .style(button::Style::Custom { + active: Box::new(move |focused, theme| { + let mut appearance = ::active( + theme, + focused, + selected, + &cosmic::theme::Button::Image, + ); + appearance.background = Some(background); + appearance + }), + disabled: Box::new(move |theme| { + let mut appearance = ::disabled( + theme, + &cosmic::theme::Button::Image, + ); + appearance.background = Some(background); + appearance + }), + hovered: Box::new(move |focused, theme| { + let mut appearance = ::hovered( + theme, + focused, + selected, + &cosmic::theme::Button::Image, + ); + appearance.background = Some(background); + appearance + }), + pressed: Box::new(move |focused, theme| { + let mut appearance = ::pressed( + theme, + focused, + selected, + &cosmic::theme::Button::Image, + ); + appearance.background = Some(background); + appearance + }), + }), + ) + .push( + text(if name.len() > ICON_NAME_TRUNC { + format!("{name:.ICON_NAME_TRUNC$}...") + } else { + name.into() + }) + .width(Length::Fixed((ICON_THUMB_SIZE * 3) as _)), + ) + .spacing(theme.space_xs()) + .into() +} diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index fe56ef4..c7f6a6f 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -49,6 +49,8 @@ control-tint = Control component tint frosted = Frosted glass effect on system interface .desc = Applies background blur to panel, dock, applets, launcher, and application library. +experimental-settings = Experimental settings + enable-export = Apply this theme to GNOME apps. .desc = Not all toolkits support auto-switching. Non-COSMIC apps may need to be restarted after a theme change.