cosmic-settings/app/src/pages/desktop/appearance.rs
2024-03-19 16:51:16 +01:00

1538 lines
60 KiB
Rust

// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::path::Path;
use std::sync::Arc;
use apply::Apply;
use ashpd::desktop::file_chooser::{FileFilter, SelectedFiles};
use cosmic::config::CosmicTk;
use cosmic::cosmic_config::{Config, ConfigSet, CosmicConfigEntry};
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_widget::scrollable;
use cosmic::prelude::CollectionWidget;
use cosmic::widget::dropdown;
use cosmic::widget::icon::{from_name, icon};
use cosmic::widget::{
button, color_picker::ColorPickerUpdate, container, horizontal_space, row, settings,
spin_button, text, ColorPickerModel,
};
use cosmic::{command, Command, Element};
use cosmic_settings_page::Section;
use cosmic_settings_page::{self as page, section};
use cosmic_settings_wallpaper as wallpaper;
use ron::ser::PrettyConfig;
use slotmap::SlotMap;
use tokio::io::AsyncBufReadExt;
use crate::app;
use super::wallpaper::widgets::color_image;
type IconThemes = Vec<String>;
crate::cache_dynamic_lazy! {
static HEX: String = fl!("hex");
static RGB: String = fl!("rgb");
static RESET_TO_DEFAULT: String = fl!("reset-to-default");
static ICON_THEME: String = fl!("icon-theme");
static ICON_THEME_DESC: String = fl!("icon-theme", "desc");
}
#[derive(Clone, Copy, Debug)]
enum ContextView {
AccentWindowHint,
ApplicationBackground,
ContainerBackground,
ControlComponent,
CustomAccent,
InterfaceText,
}
// TODO integrate with settings backend
pub struct Page {
can_reset: bool,
no_custom_window_hint: bool,
context_view: Option<ContextView>,
custom_accent: ColorPickerModel,
accent_window_hint: ColorPickerModel,
application_background: ColorPickerModel,
container_background: ColorPickerModel,
interface_text: ColorPickerModel,
control_component: ColorPickerModel,
roundness: Roundness,
icon_theme_active: Option<usize>,
icon_themes: Vec<String>,
theme_mode: ThemeMode,
theme_mode_config: Option<Config>,
theme_builder: ThemeBuilder,
theme_builder_needs_update: bool,
theme_builder_config: Option<Config>,
tk: CosmicTk,
tk_config: Option<Config>,
}
impl Default for Page {
fn default() -> Self {
let theme_mode_config = ThemeMode::config().ok();
let theme_mode = theme_mode_config
.as_ref()
.map(|c| match ThemeMode::get_entry(c) {
Ok(t) => t,
Err((errors, t)) => {
for e in errors {
tracing::error!("{e}");
}
t
}
})
.unwrap_or_default();
(theme_mode_config, theme_mode).into()
}
}
impl
From<(
Option<Config>,
ThemeMode,
Option<Config>,
ThemeBuilder,
Option<Config>,
CosmicTk,
)> for Page
{
fn from(
(theme_mode_config, theme_mode, theme_builder_config, theme_builder, tk_config, tk): (
Option<Config>,
ThemeMode,
Option<Config>,
ThemeBuilder,
Option<Config>,
CosmicTk,
),
) -> Self {
let theme = if theme_mode.is_dark {
Theme::dark_default()
} else {
Theme::light_default()
};
let custom_accent = theme_builder.accent.filter(|c| {
let c = Srgba::new(c.red, c.green, c.blue, 1.0);
c != theme.palette.accent_blue
&& c != theme.palette.accent_green
&& c != theme.palette.accent_indigo
&& c != theme.palette.accent_orange
&& c != theme.palette.accent_pink
&& c != theme.palette.accent_purple
&& c != theme.palette.accent_red
&& c != theme.palette.accent_warm_grey
&& c != theme.palette.accent_yellow
});
Self {
can_reset: if theme_mode.is_dark {
theme_builder == ThemeBuilder::dark()
} else {
theme_builder == ThemeBuilder::light()
},
theme_builder_needs_update: false,
context_view: None,
roundness: theme_builder.corner_radii.into(),
custom_accent: ColorPickerModel::new(
&*HEX,
&*RGB,
None,
custom_accent.map(Color::from),
),
application_background: ColorPickerModel::new(
&*HEX,
&*RGB,
Some(theme.background.base.into()),
theme_builder.bg_color.map(Color::from),
),
container_background: ColorPickerModel::new(
&*HEX,
&*RGB,
None,
theme_builder.primary_container_bg.map(Color::from),
),
interface_text: ColorPickerModel::new(
&*HEX,
&*RGB,
Some(theme.background.on.into()),
theme_builder.text_tint.map(Color::from),
),
control_component: ColorPickerModel::new(
&*HEX,
&*RGB,
Some(theme.palette.neutral_5.into()),
theme_builder.neutral_tint.map(Color::from),
),
accent_window_hint: ColorPickerModel::new(
&*HEX,
&*RGB,
None,
theme_builder.window_hint.map(Color::from),
),
no_custom_window_hint: theme_builder.accent.is_some(),
icon_theme_active: None,
icon_themes: Vec::new(),
theme_mode_config,
theme_builder_config,
theme_mode,
theme_builder,
tk_config,
tk,
}
}
}
impl From<(Option<Config>, ThemeMode)> for Page {
fn from((theme_mode_config, theme_mode): (Option<Config>, ThemeMode)) -> Self {
let theme_builder_config = if theme_mode.is_dark {
ThemeBuilder::dark_config()
} else {
ThemeBuilder::light_config()
}
.ok();
let theme_builder = theme_builder_config.as_ref().map_or_else(
|| {
if theme_mode.is_dark {
ThemeBuilder::dark()
} else {
ThemeBuilder::light()
}
},
|c| match ThemeBuilder::get_entry(c) {
Ok(t) => t,
Err((errors, t)) => {
for e in errors {
tracing::error!("{e}");
}
t
}
},
);
let tk_config = CosmicTk::config().ok();
let tk = match tk_config.as_ref().map(CosmicTk::get_entry) {
Some(Ok(c)) => c,
Some(Err((errs, c))) => {
for err in errs {
tracing::error!(?err, "Error loading CosmicTk");
}
c
}
None => CosmicTk::default(),
};
(
theme_mode_config,
theme_mode,
theme_builder_config,
theme_builder,
tk_config,
tk,
)
.into()
}
}
#[derive(Debug, Clone)]
pub enum Message {
AccentWindowHint(ColorPickerUpdate),
ApplicationBackground(ColorPickerUpdate),
ApplyThemeGlobal(bool),
Autoswitch(bool),
ContainerBackground(ColorPickerUpdate),
ControlComponent(ColorPickerUpdate),
CustomAccent(ColorPickerUpdate),
DarkMode(bool),
Entered(IconThemes),
ExportError,
ExportFile(Arc<SelectedFiles>),
ExportSuccess,
Frosted(bool),
GapSize(spin_button::Message),
IconTheme(usize),
ImportError,
ImportFile(Arc<SelectedFiles>),
ImportSuccess(Box<ThemeBuilder>),
InterfaceText(ColorPickerUpdate),
Left,
PaletteAccent(cosmic::iced::Color),
Reset,
Roundness(Roundness),
StartExport,
StartImport,
UseDefaultWindowHint(bool),
WindowHintSize(spin_button::Message),
}
#[derive(Debug, Clone, Copy)]
pub enum Roundness {
Round,
SlightlyRound,
Square,
}
impl From<Roundness> for CornerRadii {
fn from(value: Roundness) -> Self {
match value {
Roundness::Round => CornerRadii {
radius_0: [0.0; 4],
radius_xs: [4.0; 4],
radius_s: [8.0; 4],
radius_m: [16.0; 4],
radius_l: [32.0; 4],
radius_xl: [160.0; 4],
},
Roundness::SlightlyRound => CornerRadii {
radius_0: [0.0; 4],
radius_xs: [2.0; 4],
radius_s: [8.0; 4],
radius_m: [8.0; 4],
radius_l: [8.0; 4],
radius_xl: [8.0; 4],
},
Roundness::Square => CornerRadii {
radius_0: [0.0; 4],
radius_xs: [2.0; 4],
radius_s: [2.0; 4],
radius_m: [2.0; 4],
radius_l: [2.0; 4],
radius_xl: [2.0; 4],
},
}
}
}
impl From<CornerRadii> for Roundness {
fn from(value: CornerRadii) -> Self {
if (value.radius_m[0] - 16.0).abs() < 0.01 {
Self::Round
} else if (value.radius_m[0] - 8.0).abs() < 0.01 {
Self::SlightlyRound
} else {
Self::Square
}
}
}
impl Page {
/// Syncs changes for dark and light theme.
/// Roundness and window management settings should be consistent between dark / light mode.
fn sync_changes(&self) -> Result<(), cosmic::cosmic_config::Error> {
let (other_builder_config, other_theme_config) = if self.theme_mode.is_dark {
(ThemeBuilder::light_config()?, Theme::light_config()?)
} else {
(ThemeBuilder::dark_config()?, Theme::dark_config()?)
};
let mut theme_builder = match ThemeBuilder::get_entry(&other_builder_config) {
Ok(t) => t,
Err((errs, t)) => {
for err in errs {
tracing::error!(?err, "Error loading theme builder");
}
t
}
};
let mut theme = match Theme::get_entry(&other_theme_config) {
Ok(t) => t,
Err((errs, t)) => {
for err in errs {
tracing::error!(?err, "Error loading theme");
}
t
}
};
if theme_builder.active_hint != self.theme_builder.active_hint {
if let Err(err) =
theme_builder.set_active_hint(&other_builder_config, self.theme_builder.active_hint)
{
tracing::error!(?err, "Error setting active hint");
}
if let Err(err) =
theme.set_active_hint(&other_theme_config, self.theme_builder.active_hint)
{
tracing::error!(?err, "Error setting active hint");
}
}
if theme_builder.gaps != self.theme_builder.gaps {
if let Err(err) = theme_builder.set_gaps(&other_builder_config, self.theme_builder.gaps)
{
tracing::error!(?err, "Error setting gaps");
}
if let Err(err) = theme.set_gaps(&other_theme_config, self.theme_builder.gaps) {
tracing::error!(?err, "Error setting gaps");
}
}
if theme_builder.corner_radii != self.theme_builder.corner_radii {
if let Err(err) = theme_builder
.set_corner_radii(&other_builder_config, self.theme_builder.corner_radii)
{
tracing::error!(?err, "Error setting corner radii");
}
if let Err(err) =
theme.set_corner_radii(&other_theme_config, self.theme_builder.corner_radii)
{
tracing::error!(?err, "Error setting corner radii");
}
}
Ok(())
}
fn color_picker_context_view(
&self,
description: Option<Cow<'static, str>>,
reset: Cow<'static, str>,
on_update: fn(ColorPickerUpdate) -> Message,
model: impl Fn(&Self) -> &ColorPickerModel,
) -> Element<'_, crate::pages::Message> {
cosmic::widget::column()
.push_maybe(description.map(|description| text(description).width(Length::Fill)))
.push(
model(self)
.builder(on_update)
.reset_label(reset)
.height(Length::Fixed(158.0))
.build(
fl!("recent-colors"),
fl!("copy-to-clipboard"),
fl!("copied-to-clipboard"),
)
.apply(container)
.width(Length::Fixed(248.0))
.align_x(alignment::Horizontal::Center)
.apply(container)
.width(Length::Fill)
.align_x(alignment::Horizontal::Center),
)
.padding(self.theme_builder.spacing.space_l)
.align_items(cosmic::iced_core::Alignment::Center)
.spacing(self.theme_builder.spacing.space_m)
.width(Length::Fill)
.apply(Element::from)
.map(crate::pages::Message::Appearance)
}
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, message: Message) -> Command<app::Message> {
self.theme_builder_needs_update = false;
let mut needs_sync = false;
let mut ret = match message {
Message::DarkMode(enabled) => {
self.theme_mode.is_dark = enabled;
if let Some(config) = self.theme_mode_config.as_ref() {
// only update dark mode if autoswitch is disabled
if !self.theme_mode.auto_switch {
_ = config.set::<bool>("is_dark", enabled);
}
}
*self = Self::from((self.theme_mode_config.clone(), self.theme_mode));
let theme_builder = self.theme_builder.clone();
Command::perform(async {}, |()| {
crate::Message::SetTheme(cosmic::theme::Theme::custom(Arc::new(
// TODO set the values of the theme builder
theme_builder.build(),
)))
})
}
Message::Autoswitch(enabled) => {
self.theme_mode.auto_switch = enabled;
if let Some(config) = self.theme_mode_config.as_ref() {
_ = config.set::<bool>("auto_switch", enabled);
}
Command::none()
}
Message::AccentWindowHint(u) => {
needs_sync = true;
let cmd = self.update_color_picker(
&u,
ContextView::AccentWindowHint,
fl!("window-hint-accent").into(),
);
Command::batch(vec![cmd, self.accent_window_hint.update::<app::Message>(u)])
}
Message::Frosted(enabled) => {
self.theme_builder_needs_update = true;
self.theme_builder.is_frosted = enabled;
Command::none()
}
Message::IconTheme(id) => {
if let Some(theme) = self.icon_themes.get(id) {
self.icon_theme_active = Some(id);
self.tk.icon_theme = theme.clone();
if let Some(ref config) = self.tk_config {
let _ = self.tk.write_entry(config);
}
}
Command::none()
}
Message::WindowHintSize(msg) => {
needs_sync = true;
self.theme_builder_needs_update = true;
self.theme_builder.active_hint = match msg {
spin_button::Message::Increment => {
self.theme_builder.active_hint.saturating_add(1)
}
spin_button::Message::Decrement => {
self.theme_builder.active_hint.saturating_sub(1)
}
};
Command::none()
}
Message::GapSize(msg) => {
needs_sync = true;
self.theme_builder_needs_update = true;
self.theme_builder.gaps.1 = match msg {
spin_button::Message::Increment => self.theme_builder.gaps.1.saturating_add(1),
spin_button::Message::Decrement => self.theme_builder.gaps.1.saturating_sub(1),
};
Command::none()
}
Message::ApplicationBackground(u) => {
let cmd = self.update_color_picker(
&u,
ContextView::ApplicationBackground,
fl!("app-background").into(),
);
Command::batch(vec![
cmd,
self.application_background.update::<app::Message>(u),
])
}
Message::ContainerBackground(u) => {
let cmd = self.update_color_picker(
&u,
ContextView::ContainerBackground,
fl!("container-background").into(),
);
Command::batch(vec![
cmd,
self.container_background.update::<app::Message>(u),
])
}
Message::CustomAccent(u) => {
let cmd = self.update_color_picker(
&u,
ContextView::CustomAccent,
fl!("accent-color").into(),
);
let cmd2 = self.custom_accent.update::<app::Message>(u);
self.theme_builder.accent = self.custom_accent.get_applied_color().map(Srgb::from);
Command::batch(vec![cmd, cmd2])
}
Message::InterfaceText(u) => {
let cmd = self.update_color_picker(
&u,
ContextView::InterfaceText,
fl!("text-tint").into(),
);
Command::batch(vec![cmd, self.interface_text.update::<app::Message>(u)])
}
Message::ControlComponent(u) => {
let cmd = self.update_color_picker(
&u,
ContextView::ControlComponent,
fl!("control-tint").into(),
);
Command::batch(vec![cmd, self.control_component.update::<app::Message>(u)])
}
Message::Roundness(r) => {
needs_sync = true;
self.roundness = r;
self.theme_builder.corner_radii = self.roundness.into();
self.theme_builder_needs_update = true;
Command::none()
}
Message::Entered(icon_themes) => {
*self = Self::default();
// Set the icon themes, and define the active icon theme.
self.icon_themes = icon_themes;
self.icon_theme_active = self
.icon_themes
.iter()
.position(|theme| theme == &self.tk.icon_theme);
let theme_builder = self.theme_builder.clone();
cosmic::command::future(async {
crate::Message::SetTheme(cosmic::theme::Theme::custom(Arc::new(
// TODO set the values of the theme builder
theme_builder.build(),
)))
})
// Load the current theme builders and mode
// Set the theme for the application to match the current mode instead of the system theme?
}
Message::Left => Command::perform(async {}, |()| {
app::Message::SetTheme(cosmic::theme::system_preference())
}),
Message::PaletteAccent(c) => {
self.theme_builder.accent = Some(c.into());
self.theme_builder_needs_update = true;
Command::none()
}
Message::Reset => {
self.theme_builder = if self.theme_mode.is_dark {
cosmic::cosmic_config::Config::system(
DARK_THEME_BUILDER_ID,
ThemeBuilder::VERSION,
)
.map_or_else(
|_| ThemeBuilder::dark(),
|config| match ThemeBuilder::get_entry(&config) {
Ok(t) => t,
Err((errs, t)) => {
for err in errs {
tracing::warn!(?err, "Error getting system theme builder");
}
t
}
},
)
} else {
cosmic::cosmic_config::Config::system(
LIGHT_THEME_BUILDER_ID,
ThemeBuilder::VERSION,
)
.map_or_else(
|_| ThemeBuilder::light(),
|config| match ThemeBuilder::get_entry(&config) {
Ok(t) => t,
Err((errs, t)) => {
for err in errs {
tracing::warn!(?err, "Error getting system theme builder");
}
t
}
},
)
};
if let Some(config) = self.theme_builder_config.as_ref() {
_ = self.theme_builder.write_entry(config);
};
let config = if self.theme_mode.is_dark {
Theme::dark_config()
} else {
Theme::light_config()
};
let new_theme = self.theme_builder.clone().build();
if let Ok(config) = config {
_ = new_theme.write_entry(&config);
} else {
tracing::error!("Failed to get the theme config.");
}
*self = Self::from((self.theme_mode_config.clone(), self.theme_mode));
Command::perform(async {}, |()| {
crate::Message::SetTheme(cosmic::theme::Theme::custom(Arc::new(new_theme)))
})
}
Message::StartImport => Command::perform(
async {
SelectedFiles::open_file()
.modal(true)
.filter(FileFilter::glob(FileFilter::new("ron"), "*.ron"))
.send()
.await?
.response()
},
|res| {
if let Ok(f) = res {
crate::Message::PageMessage(crate::pages::Message::Appearance(
Message::ImportFile(Arc::new(f)),
))
} else {
// TODO Error toast?
tracing::error!("failed to select a file for importing a custom theme.");
crate::Message::PageMessage(crate::pages::Message::Appearance(
Message::ImportError,
))
}
},
),
Message::StartExport => {
let is_dark = self.theme_mode.is_dark;
let name = format!("{}.ron", if is_dark { fl!("dark") } else { fl!("light") });
Command::perform(
async move {
SelectedFiles::save_file()
.modal(true)
.current_name(Some(name.as_str()))
.send()
.await?
.response()
},
|res| {
if let Ok(f) = res {
crate::Message::PageMessage(crate::pages::Message::Appearance(
Message::ExportFile(Arc::new(f)),
))
} else {
// TODO Error toast?
tracing::error!(
"failed to select a file for importing a custom theme."
);
crate::Message::PageMessage(crate::pages::Message::Appearance(
Message::ExportError,
))
}
},
)
}
Message::ImportFile(f) => {
let Some(f) = f.uris().first() else {
return Command::none();
};
if f.scheme() != "file" {
return Command::none();
}
let Ok(path) = f.to_file_path() else {
return Command::none();
};
Command::perform(
async move { tokio::fs::read_to_string(path).await },
|res| {
if let Some(b) = res.ok().and_then(|s| ron::de::from_str(&s).ok()) {
crate::Message::PageMessage(crate::pages::Message::Appearance(
Message::ImportSuccess(Box::new(b)),
))
} else {
// TODO Error toast?
tracing::error!("failed to import a file for a custom theme.");
crate::Message::PageMessage(crate::pages::Message::Appearance(
Message::ImportError,
))
}
},
)
}
Message::ExportFile(f) => {
let Some(f) = f.uris().first() else {
return Command::none();
};
if f.scheme() != "file" {
return Command::none();
}
let Ok(path) = f.to_file_path() else {
return Command::none();
};
let Ok(builder) =
ron::ser::to_string_pretty(&self.theme_builder, PrettyConfig::default())
else {
return Command::none();
};
Command::perform(
async move { tokio::fs::write(path, builder).await },
|res| {
if res.is_ok() {
crate::Message::PageMessage(crate::pages::Message::Appearance(
Message::ExportSuccess,
))
} else {
// TODO Error toast?
tracing::error!(
"failed to select a file for importing a custom theme."
);
crate::Message::PageMessage(crate::pages::Message::Appearance(
Message::ExportError,
))
}
},
)
}
// TODO: error message toast?
Message::ExportError | Message::ImportError => Command::none(),
Message::ExportSuccess => {
tracing::trace!("Export successful");
Command::none()
}
Message::ImportSuccess(builder) => {
tracing::trace!("Import successful");
self.theme_builder = *builder;
if let Some(config) = self.theme_builder_config.as_ref() {
_ = self.theme_builder.write_entry(config);
};
let config = if self.theme_mode.is_dark {
Theme::dark_config()
} else {
Theme::light_config()
};
let new_theme = self.theme_builder.clone().build();
if let Ok(config) = config {
_ = new_theme.write_entry(&config);
} else {
tracing::error!("Failed to get the theme config.");
}
*self = Self::from((self.theme_mode_config.clone(), self.theme_mode));
Command::perform(async {}, |()| {
crate::Message::SetTheme(cosmic::theme::Theme::custom(Arc::new(new_theme)))
})
}
Message::UseDefaultWindowHint(v) => {
self.no_custom_window_hint = v;
self.theme_builder_needs_update = true;
let theme = if self.theme_mode.is_dark {
Theme::dark_default()
} else {
Theme::light_default()
};
if !v {
let window_hint = self
.theme_builder
.window_hint
.filter(|c| {
let c = Srgba::new(c.red, c.green, c.blue, 1.0);
c != theme.palette.accent_blue
&& c != theme.palette.accent_green
&& c != theme.palette.accent_indigo
&& c != theme.palette.accent_orange
&& c != theme.palette.accent_pink
&& c != theme.palette.accent_purple
&& c != theme.palette.accent_red
&& c != theme.palette.accent_warm_grey
&& c != theme.palette.accent_yellow
})
.unwrap_or(
self.custom_accent
.get_applied_color()
.unwrap_or_default()
.into(),
);
_ = self.accent_window_hint.update::<app::Message>(
ColorPickerUpdate::ActiveColor(Hsv::from_color(window_hint)),
);
};
Command::none()
}
Message::ApplyThemeGlobal(enabled) => {
if let Some(tk_config) = self.tk_config.as_ref() {
_ = self.tk.set_apply_theme_global(tk_config, enabled);
} else {
tracing::error!("Failed to apply theme to GNOME config because the CosmicTK config does not exist.");
}
Command::none()
}
};
if self.theme_builder_needs_update {
let Some(config) = self.theme_builder_config.as_ref() else {
return ret;
};
let mut theme_builder = std::mem::take(&mut self.theme_builder);
theme_builder.bg_color = self
.application_background
.get_applied_color()
.map(Srgba::from);
theme_builder.primary_container_bg = self
.container_background
.get_applied_color()
.map(Srgba::from);
theme_builder.text_tint = self.interface_text.get_applied_color().map(Srgb::from);
theme_builder.neutral_tint = self.control_component.get_applied_color().map(Srgb::from);
theme_builder.window_hint = if self.no_custom_window_hint {
None
} else {
self.accent_window_hint.get_applied_color().map(Srgb::from)
};
_ = theme_builder.write_entry(config);
self.theme_builder = theme_builder;
let config = if self.theme_mode.is_dark {
Theme::dark_config()
} else {
Theme::light_config()
};
if let Ok(config) = config {
let new_theme = self.theme_builder.clone().build();
_ = new_theme.write_entry(&config);
} else {
tracing::error!("Failed to get the theme config.");
}
let theme_builder = self.theme_builder.clone();
ret = Command::batch(vec![
ret,
Command::perform(async {}, |()| {
crate::Message::SetTheme(cosmic::theme::Theme::custom(Arc::new(
theme_builder.build(),
)))
}),
]);
}
self.can_reset = if self.theme_mode.is_dark {
self.theme_builder != ThemeBuilder::dark()
} else {
self.theme_builder != ThemeBuilder::light()
};
if needs_sync {
if let Err(err) = self.sync_changes() {
tracing::error!(?err, "Error syncing theme changes.");
}
}
ret
}
fn update_color_picker(
&mut self,
message: &ColorPickerUpdate,
context_view: ContextView,
context_title: Cow<'static, str>,
) -> Command<app::Message> {
match message {
ColorPickerUpdate::AppliedColor | ColorPickerUpdate::Reset => {
self.theme_builder_needs_update = true;
cosmic::command::message(crate::app::Message::CloseContextDrawer)
}
ColorPickerUpdate::ActionFinished => {
self.theme_builder_needs_update = true;
Command::none()
}
ColorPickerUpdate::Cancel => {
cosmic::command::message(crate::app::Message::CloseContextDrawer)
}
ColorPickerUpdate::ToggleColorPicker => {
self.context_view = Some(context_view);
cosmic::command::message(crate::app::Message::OpenContextDrawer(context_title))
}
_ => Command::none(),
}
}
}
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(mode_and_colors()),
sections.insert(style()),
sections.insert(window_management()),
sections.insert(reset_button()),
])
}
fn header_view(&self) -> Option<Element<'_, crate::pages::Message>> {
let spacing = self.theme_builder.spacing;
let content = row::with_capacity(2)
.spacing(self.theme_builder.spacing.space_xxs)
.push(
button(text(fl!("import")))
.on_press(Message::StartImport)
.padding([spacing.space_xxs, spacing.space_xs]),
)
.push(
button(text(fl!("export")))
.on_press(Message::StartExport)
.padding([spacing.space_xxs, spacing.space_xs]),
)
.apply(container)
.width(Length::Fill)
.align_x(alignment::Horizontal::Right)
.apply(Element::from)
.map(crate::pages::Message::Appearance);
Some(content)
}
fn info(&self) -> page::Info {
page::Info::new("appearance", "preferences-appearance-symbolic")
.title(fl!("appearance"))
.description(fl!("appearance", "desc"))
}
fn reload(&mut self, _: page::Entity) -> Command<crate::pages::Message> {
command::future(fetch_icon_themes()).map(crate::pages::Message::Appearance)
}
fn on_leave(&mut self) -> Command<crate::pages::Message> {
command::message(crate::pages::Message::Appearance(Message::Left))
}
fn context_drawer(&self) -> Option<Element<'_, crate::pages::Message>> {
let view = match self.context_view? {
ContextView::AccentWindowHint => self.color_picker_context_view(
None,
RESET_TO_DEFAULT.as_str().into(),
Message::AccentWindowHint,
|this| &this.accent_window_hint,
),
ContextView::ApplicationBackground => self.color_picker_context_view(
None,
RESET_TO_DEFAULT.as_str().into(),
Message::ApplicationBackground,
|this| &this.application_background,
),
ContextView::ContainerBackground => self.color_picker_context_view(
Some(fl!("container-background", "desc-detail").into()),
fl!("container-background", "reset").into(),
Message::ContainerBackground,
|this| &this.container_background,
),
ContextView::ControlComponent => self.color_picker_context_view(
None,
RESET_TO_DEFAULT.as_str().into(),
Message::ControlComponent,
|this| &this.control_component,
),
ContextView::CustomAccent => self.color_picker_context_view(
None,
RESET_TO_DEFAULT.as_str().into(),
Message::CustomAccent,
|this| &this.custom_accent,
),
ContextView::InterfaceText => self.color_picker_context_view(
None,
RESET_TO_DEFAULT.as_str().into(),
Message::InterfaceText,
|this| &this.interface_text,
),
};
Some(view)
}
}
#[allow(clippy::too_many_lines)]
pub fn mode_and_colors() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("mode-and-colors"))
.descriptions(vec![
// 0
fl!("auto-switch").into(),
fl!("auto-switch", "desc").into(),
//2
fl!("accent-color").into(),
//3
fl!("app-background").into(),
//4
fl!("container-background").into(),
fl!("container-background", "desc").into(),
fl!("container-background", "desc-detail").into(),
fl!("container-background", "reset").into(),
// 8
fl!("text-tint").into(),
fl!("text-tint", "desc").into(),
// 10
fl!("control-tint").into(),
fl!("control-tint", "desc").into(),
// 12
fl!("window-hint-accent-toggle").into(),
fl!("window-hint-accent").into(),
// 14
fl!("dark").into(),
fl!("light").into(),
])
.view::<Page>(|_binder, page, section| {
let descriptions = &section.descriptions;
let palette = &page.theme_builder.palette.as_ref();
let cur_accent = page
.theme_builder
.accent
.map_or(palette.accent_blue, Srgba::from);
let mut section = settings::view_section(&section.title)
.add(
container(
cosmic::iced::widget::row![
cosmic::iced::widget::column![
button(
icon(from_name("illustration-appearance-mode-dark").into(),)
.width(Length::Fill)
.height(Length::Fixed(100.0))
)
.style(button::Style::Image)
.padding([8, 0])
.selected(page.theme_mode.is_dark)
.on_press(Message::DarkMode(true)),
text(&*descriptions[14])
]
.spacing(8)
.width(Length::FillPortion(1))
.align_items(cosmic::iced_core::Alignment::Center),
cosmic::iced::widget::column![
button(
icon(from_name("illustration-appearance-mode-light").into(),)
.width(Length::Fill)
.height(Length::Fixed(100.0))
)
.style(button::Style::Image)
.selected(!page.theme_mode.is_dark)
.padding([8, 0])
.on_press(Message::DarkMode(false)),
text(&*descriptions[15])
]
.spacing(8)
.width(Length::FillPortion(1))
.align_items(cosmic::iced_core::Alignment::Center)
]
.spacing(48)
.align_items(cosmic::iced_core::Alignment::Center)
.width(Length::Fixed(424.0)),
)
.width(Length::Fill)
.align_x(cosmic::iced_core::alignment::Horizontal::Center),
)
.add(
settings::item::builder(&*descriptions[0])
.description(&*descriptions[1])
.toggler(page.theme_mode.auto_switch, Message::Autoswitch),
)
.add(
cosmic::iced::widget::column![
text(&*descriptions[2]),
scrollable(
cosmic::iced::widget::row![
color_button(
Some(Message::PaletteAccent(palette.accent_blue.into())),
palette.accent_blue.into(),
cur_accent == palette.accent_blue,
48,
48
),
color_button(
Some(Message::PaletteAccent(palette.accent_indigo.into())),
palette.accent_indigo.into(),
cur_accent == palette.accent_indigo,
48,
48
),
color_button(
Some(Message::PaletteAccent(palette.accent_purple.into())),
palette.accent_purple.into(),
cur_accent == palette.accent_purple,
48,
48
),
color_button(
Some(Message::PaletteAccent(palette.accent_pink.into())),
palette.accent_pink.into(),
cur_accent == palette.accent_pink,
48,
48
),
color_button(
Some(Message::PaletteAccent(palette.accent_red.into())),
palette.accent_red.into(),
cur_accent == palette.accent_red,
48,
48
),
color_button(
Some(Message::PaletteAccent(palette.accent_orange.into())),
palette.accent_orange.into(),
cur_accent == palette.accent_orange,
48,
48
),
color_button(
Some(Message::PaletteAccent(palette.accent_yellow.into())),
palette.accent_yellow.into(),
cur_accent == palette.accent_yellow,
48,
48
),
color_button(
Some(Message::PaletteAccent(palette.accent_green.into())),
palette.accent_green.into(),
cur_accent == palette.accent_green,
48,
48
),
color_button(
Some(Message::PaletteAccent(palette.accent_warm_grey.into())),
palette.accent_warm_grey.into(),
cur_accent == palette.accent_warm_grey,
48,
48
),
if let Some(c) = page.custom_accent.get_applied_color() {
container(color_button(
Some(Message::CustomAccent(
ColorPickerUpdate::ToggleColorPicker,
)),
c,
cosmic::iced::Color::from(cur_accent) == c,
48,
48,
))
} else {
container(
page.custom_accent
.picker_button(Message::CustomAccent, None)
.width(Length::Fixed(48.0))
.height(Length::Fixed(48.0)),
)
},
]
.padding([0, 0, 16, 0])
.spacing(16)
)
.direction(scrollable::Direction::Horizontal(
scrollable::Properties::new()
))
]
.padding([16, 24, 0, 24])
.spacing(8),
)
.add(
settings::item::builder(&*descriptions[3]).control(
page.application_background
.picker_button(Message::ApplicationBackground, Some(24))
.width(Length::Fixed(48.0))
.height(Length::Fixed(24.0)),
),
)
.add(
settings::item::builder(&*descriptions[4])
.description(&*descriptions[5])
.control(if page.container_background.get_applied_color().is_some() {
Element::from(
page.container_background
.picker_button(Message::ContainerBackground, Some(24))
.width(Length::Fixed(48.0))
.height(Length::Fixed(24.0)),
)
} else {
container(
button::text(fl!("auto"))
.trailing_icon(from_name("go-next-symbolic"))
.on_press(Message::ContainerBackground(
ColorPickerUpdate::ToggleColorPicker,
)),
)
.into()
}),
)
.add(
settings::item::builder(&*descriptions[8])
.description(&*descriptions[9])
.control(
page.interface_text
.picker_button(Message::InterfaceText, Some(24))
.width(Length::Fixed(48.0))
.height(Length::Fixed(24.0)),
),
)
.add(
settings::item::builder(&*descriptions[10])
.description(&*descriptions[11])
.control(
page.control_component
.picker_button(Message::ControlComponent, Some(24))
.width(Length::Fixed(48.0))
.height(Length::Fixed(24.0)),
),
)
.add(
settings::item::builder(&*descriptions[12])
.toggler(page.no_custom_window_hint, Message::UseDefaultWindowHint),
);
if !page.no_custom_window_hint {
section = section.add(
settings::item::builder(&*descriptions[13]).control(
page.accent_window_hint
.picker_button(Message::AccentWindowHint, Some(24))
.width(Length::Fixed(48.0))
.height(Length::Fixed(24.0)),
),
);
}
section
.apply(Element::from)
.map(crate::pages::Message::Appearance)
})
}
#[allow(clippy::too_many_lines)]
pub fn style() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("style"))
.descriptions(vec![
fl!("style", "round").into(),
fl!("style", "slightly-round").into(),
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::<Page>(|_binder, page, section| {
let descriptions = &section.descriptions;
settings::view_section(&section.title)
.add(
container(
cosmic::iced::widget::row![
cosmic::iced::widget::column![
button(
icon(
from_name(if page.theme_mode.is_dark {
"illustration-appearance-dark-style-round"
} else {
"illustration-appearance-light-style-round"
})
.into()
)
.width(Length::Fill)
.height(Length::Fixed(100.0))
)
.selected(matches!(page.roundness, Roundness::Round))
.style(button::Style::Image)
.padding(8)
.on_press(Message::Roundness(Roundness::Round)),
text(&*descriptions[0])
]
.spacing(8)
.width(Length::FillPortion(1))
.align_items(cosmic::iced_core::Alignment::Center),
cosmic::iced::widget::column![
button(
icon(
from_name(if page.theme_mode.is_dark {
"illustration-appearance-dark-style-slightly-round"
} else {
"illustration-appearance-light-style-slightly-round"
})
.into()
)
.width(Length::Fill)
.height(Length::Fixed(100.0))
)
.selected(matches!(page.roundness, Roundness::SlightlyRound))
.style(button::Style::Image)
.padding(8)
.on_press(Message::Roundness(Roundness::SlightlyRound)),
text(&*descriptions[1])
]
.spacing(8)
.width(Length::FillPortion(1))
.align_items(cosmic::iced_core::Alignment::Center),
cosmic::iced::widget::column![
button(
icon(
from_name(if page.theme_mode.is_dark {
"illustration-appearance-dark-style-square"
} else {
"illustration-appearance-light-style-square"
})
.into(),
)
.width(Length::Fill)
.height(Length::Fixed(100.0))
)
.width(Length::FillPortion(1))
.selected(matches!(page.roundness, Roundness::Square))
.style(button::Style::Image)
.padding(8)
.on_press(Message::Roundness(Roundness::Square)),
text(&*descriptions[2])
]
.spacing(8)
.align_items(cosmic::iced_core::Alignment::Center)
.width(Length::FillPortion(1))
]
.spacing(12)
.width(Length::Fixed(628.0))
.align_items(cosmic::iced_core::Alignment::Center),
)
.width(Length::Fill)
.align_x(cosmic::iced_core::alignment::Horizontal::Center),
)
.add(
settings::item::builder(&*descriptions[3])
.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)
})
}
#[allow(clippy::too_many_lines)]
pub fn window_management() -> Section<crate::pages::Message> {
Section::default()
.title(fl!("window-management"))
.descriptions(vec![
fl!("window-management", "active-hint").into(),
fl!("window-management", "gaps").into(),
])
.view::<Page>(|_binder, page, section| {
let descriptions = &section.descriptions;
settings::view_section(&section.title)
.add(settings::item::builder(&*descriptions[0]).control(
cosmic::widget::spin_button(
page.theme_builder.active_hint.to_string(),
Message::WindowHintSize,
),
))
.add(settings::item::builder(&*descriptions[1]).control(
cosmic::widget::spin_button(
page.theme_builder.gaps.1.to_string(),
Message::GapSize,
),
))
.apply(Element::from)
.map(crate::pages::Message::Appearance)
})
}
#[allow(clippy::too_many_lines)]
pub fn reset_button() -> Section<crate::pages::Message> {
Section::default()
.descriptions(vec![fl!("reset-to-default").into()])
.view::<Page>(|_binder, page, section| {
let spacing = &page.theme_builder.spacing;
let descriptions = &section.descriptions;
if page.can_reset {
cosmic::iced::widget::row![button(text(&*descriptions[0]))
.on_press(Message::Reset)
.padding([spacing.space_xxs, spacing.space_xs])]
.apply(Element::from)
} else {
horizontal_space(1).apply(Element::from)
}
.map(crate::pages::Message::Appearance)
})
}
impl page::AutoBind<crate::pages::Message> for Page {}
/// A button for selecting a color or gradient.
pub fn color_button<'a, Message: 'a + Clone>(
on_press: Option<Message>,
color: cosmic::iced::Color,
selected: bool,
width: u16,
height: u16,
) -> Element<'a, Message> {
button(color_image(
wallpaper::Color::Single([color.r, color.g, color.b]),
width,
height,
None,
))
.padding(0)
.selected(selected)
.style(button::Style::Image)
.on_press_maybe(on_press)
.width(Length::Fixed(f32::from(width)))
.height(Length::Fixed(f32::from(height)))
.into()
}
async fn fetch_icon_themes() -> Message {
let mut icon_themes = BTreeSet::new();
let mut buffer = String::new();
if let Ok(data_dirs) = std::env::var("XDG_DATA_DIRS") {
for dir in data_dirs.split_terminator(':') {
let icon_dir = Path::new(dir).join("icons");
let Ok(read_dir) = std::fs::read_dir(&icon_dir) else {
continue;
};
for entry in read_dir.filter_map(Result::ok) {
let Ok(path) = entry.path().canonicalize() else {
continue;
};
let manifest = path.join("index.theme");
if !manifest.exists() {
continue;
}
let Ok(file) = tokio::fs::File::open(&manifest).await else {
continue;
};
buffer.clear();
let mut name = None;
let mut line_reader = tokio::io::BufReader::new(file);
while let Ok(read) = line_reader.read_line(&mut buffer).await {
if read == 0 {
break;
}
if let Some(is_hidden) = buffer.strip_prefix("Hidden=") {
if is_hidden.trim() == "true" {
break;
}
} else if name.is_none() {
if let Some(value) = buffer.strip_prefix("Name=") {
name = Some(value.trim().to_owned());
}
}
buffer.clear();
}
if let Some(name) = name {
icon_themes.insert(name);
}
}
}
}
Message::Entered(icon_themes.into_iter().collect())
}