diff --git a/README.md b/README.md
index f20eb50..116718a 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,10 @@ raising the window; clicking raises it within its layer.
## Make it yours
-Open **Control Center → Settings**, or run `ferese-settings`.
+Open **Control Center → Settings**, or run `ferese-settings`. Appearance includes
+six presets: Ferese Blue, Monochrome, Gruvbox, Dracula, Ayu Light, and Monokai.
+The custom Ferese wallpaper is bundled as the default. Choose another image in
+Settings → Wallpaper, or keep your existing wallpaper configuration.
Advanced options live in `~/.config/ferese/config.kdl` (or under
`$XDG_CONFIG_HOME`). Changes reload automatically. The complete
[configuration reference](docs/configuration.md) covers every supported option;
diff --git a/assets/wallpapers/ferese.png b/assets/wallpapers/ferese.png
new file mode 100644
index 0000000..7c01d20
Binary files /dev/null and b/assets/wallpapers/ferese.png differ
diff --git a/assets/wallpapers/ferese.svg b/assets/wallpapers/ferese.svg
new file mode 100644
index 0000000..7387314
--- /dev/null
+++ b/assets/wallpapers/ferese.svg
@@ -0,0 +1,62 @@
+
diff --git a/compositor/src/config.rs b/compositor/src/config.rs
index 09ba67d..b26cf87 100644
--- a/compositor/src/config.rs
+++ b/compositor/src/config.rs
@@ -1674,7 +1674,7 @@ fn default_surface_base_color() -> String {
}
fn default_accent_color() -> String {
- "#5B8CFF".to_owned()
+ "#3D7BE6".to_owned()
}
fn default_shadow_color() -> String {
diff --git a/compositor/src/wallpaper.rs b/compositor/src/wallpaper.rs
index 595f78a..dc3e4fa 100644
--- a/compositor/src/wallpaper.rs
+++ b/compositor/src/wallpaper.rs
@@ -17,13 +17,27 @@ use smithay::{
};
use std::{collections::HashMap, path::PathBuf, sync::mpsc};
-#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
+#[derive(Clone, Debug, Deserialize, PartialEq)]
pub(crate) struct WallpaperConfig {
+ #[serde(default = "default_wallpaper_path")]
pub path: Option,
#[serde(default)]
pub mode: WallpaperMode,
}
+fn default_wallpaper_path() -> Option {
+ Some(PathBuf::from(ferese_config::default_wallpaper()))
+}
+
+impl Default for WallpaperConfig {
+ fn default() -> Self {
+ Self {
+ path: default_wallpaper_path(),
+ mode: WallpaperMode::default(),
+ }
+ }
+}
+
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WallpaperMode {
@@ -261,7 +275,7 @@ mod tests {
let (sender, receiver) = mpsc::channel();
state.receiver = Some(receiver);
state.reload(WallpaperConfig {
- path: None,
+ path: state.config.path.clone(),
mode: WallpaperMode::Fit,
});
assert!(state.receiver.is_some());
@@ -284,7 +298,7 @@ mod tests {
let (sender, receiver) = mpsc::channel();
state.receiver = Some(receiver);
state.reload(WallpaperConfig {
- path: None,
+ path: state.config.path.clone(),
mode: WallpaperMode::Fit,
});
state.reload(WallpaperConfig::default());
@@ -314,7 +328,18 @@ mod tests {
#[test]
fn missing_wallpaper_preserves_external_shell_fallback() {
- let state = WallpaperState::new(WallpaperConfig::default());
+ let state = WallpaperState::new(WallpaperConfig {
+ path: None,
+ mode: WallpaperMode::default(),
+ });
assert!(!state.owns_background());
}
+
+ #[test]
+ fn default_wallpaper_is_bundled_and_decodable() {
+ let config: WallpaperConfig = ferese_config::from_str("").unwrap();
+ let path = config.path.unwrap();
+ assert_eq!(image::image_dimensions(&path).unwrap(), (1920, 1080));
+ assert!(WallpaperState::new(WallpaperConfig::default()).owns_background());
+ }
}
diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs
index 723fdc9..fcc2a70 100644
--- a/crates/ferese-config/src/lib.rs
+++ b/crates/ferese-config/src/lib.rs
@@ -27,6 +27,23 @@ pub fn config_path() -> Option {
.map(|p| p.join("ferese/config.kdl"))
}
+pub fn default_wallpaper() -> &'static str {
+ static PATH: std::sync::OnceLock = std::sync::OnceLock::new();
+ PATH.get_or_init(|| {
+ let path = std::env::current_exe()
+ .ok()
+ .and_then(|path| path.parent().map(|dir| dir.join("wallpapers/ferese.png")))
+ .filter(|path| path.is_file())
+ .unwrap_or_else(|| {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/wallpapers/ferese.png")
+ });
+ path.canonicalize()
+ .unwrap_or(path)
+ .to_string_lossy()
+ .into_owned()
+ })
+}
+
fn field(name: &str, parent: &str) -> String {
if parent == "commands" {
return name.to_owned();
diff --git a/docs/configuration.md b/docs/configuration.md
index f321bf9..95b83e2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -105,6 +105,23 @@ Changing default width preserves manually resized columns.
## Appearance
+Settings → Appearance offers six presets: **Ferese Blue** (default),
+**Monochrome**, **Gruvbox**, **Dracula**, **Ayu Light**, and **Monokai**.
+Ferese Blue uses the README logo's `#3D7BE6`. Each preset coordinates surfaces,
+text, menu-bar colors, borders and the focus ring. Ayu Light also switches native
+Settings controls to light styling. Presets preserve wallpaper, typography,
+geometry and opacity settings; colors remain individually editable.
+
+The community palettes are desktop adaptations with text shades adjusted for
+readability. Sources: [Gruvbox](https://github.com/morhetz/gruvbox),
+[Dracula](https://github.com/dracula/visual-studio-code),
+[Ayu](https://github.com/ayu-theme/ayu-colors), and
+[classic Monokai](https://github.com/microsoft/vscode/blob/main/extensions/theme-monokai/themes/monokai-color-theme.json).
+Marketplace install counts informed the shortlist; they are not a measurement
+of desktop users. Monochrome and Gruvbox replace similar blue palettes to keep
+these six visually distinct.
+
+
| Section / key | Type | Default | Meaning |
| --- | --- | --- | --- |
| `appearance.corner_radius` | number ≥ 0 | `14` | Shell material/card radius |
@@ -112,7 +129,7 @@ Changing default width preserves manually resized columns.
| `appearance.inactive_dim.amount` | number 0–1 | `0.15` | Darkening strength |
| `appearance.inactive_dim.duration_ms` | number ≥ 0 | `150` | Dimming and focus-ring transition; 0 snaps |
| `theme.typography.font_family` | string | system sans-serif | Shell, Settings and overview font |
-| `theme.background.path` | string | omitted | Wallpaper image path |
+| `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default |
| `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox |
| `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material |
| `theme.material.blur_radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables |
@@ -121,8 +138,8 @@ Changing default width preserves manually resized columns.
| --- | --- | --- |
| `surface_base` | `"#111821"` | Material/card base color |
| `text_primary` | `"#F4F7FB"` | Main text |
-| `text_muted` | `"#7F8A98"` | Secondary text |
-| `accent` | `"#5B8CFF"` | Active controls and solid focus ring |
+| `text_muted` | `"#8793A2"` | Secondary text |
+| `accent` | `"#3D7BE6"` | Active controls and solid focus ring |
| `border` | `"#FFFFFF18"` | Unfocused window border |
| `shadow` | `"#00000055"` | Window shadow color |
diff --git a/packaging/config.kdl b/packaging/config.kdl
index f2b49ee..d2a1efa 100644
--- a/packaging/config.kdl
+++ b/packaging/config.kdl
@@ -20,10 +20,10 @@ layout {
theme {
surface {
bar {
- background "#1C202E"
+ background "#111821"
opacity 0.95
- text-primary "#F0F3FA"
- text-muted "#AAB4C7"
+ text-primary "#F4F7FB"
+ text-muted "#8793A2"
}
}
}
diff --git a/scripts/install-session.sh b/scripts/install-session.sh
index 7b3a531..512f3c3 100644
--- a/scripts/install-session.sh
+++ b/scripts/install-session.sh
@@ -42,6 +42,9 @@ fi
for name in ferese ferese-shell ferese-settings feresectl; do
[[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; }
done
+for name in ferese.png ferese.svg; do
+ [[ -f $repo_dir/assets/wallpapers/$name ]] || { echo "Missing wallpaper asset: $name" >&2; exit 1; }
+done
desktop-file-validate "$repo_dir/packaging/ferese.desktop"
desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop"
@@ -56,6 +59,9 @@ install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-ses
install -m 0755 -- "$repo_dir/packaging/ferese-lock" "$release_dir/ferese-lock"
install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot"
install -m 0644 -- "$repo_dir/packaging/config.kdl" "$release_dir/config.example.kdl"
+install -d -m 0755 -- "$release_dir/wallpapers"
+install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.png" "$release_dir/wallpapers/ferese.png"
+install -m 0644 -- "$repo_dir/assets/wallpapers/ferese.svg" "$release_dir/wallpapers/ferese.svg"
if [[ -L $install_root/current ]]; then
ln -sfn -- "$(readlink -- "$install_root/current")" "$install_root/previous"
diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs
index 3c40f19..c24c327 100644
--- a/shell/ferese-settings/src/main.rs
+++ b/shell/ferese-settings/src/main.rs
@@ -416,7 +416,11 @@ impl cosmic::Application for App {
Message::WallpaperPicked(Err(error)) => self.error = Some(error),
Message::Thumbnail(path, result) => {
self.thumbnail_loading = false;
- if path == self.current.string("theme.background.path", "") {
+ if path
+ == self
+ .current
+ .string("theme.background.path", ferese_config::default_wallpaper())
+ {
self.thumbnail_path = path;
match result {
Ok(handle) => {
@@ -591,27 +595,31 @@ impl cosmic::Application for App {
body = body.push(visuals::preview(&self.draft));
}
if self.page == Page::Appearance {
- let mut presets = row([]).spacing(10);
- for (index, title) in ["Amberwood", "Monochrome", "Catppuccin"]
- .into_iter()
- .enumerate()
- {
- presets = presets.push(
- button::custom(
- row([])
- .spacing(10)
- .align_y(Alignment::Center)
+ let mut presets = column([]).spacing(10);
+ for (row_index, choices) in visuals::PRESETS.chunks(3).enumerate() {
+ let mut tiles = row([]).spacing(10);
+ for (column_index, preset) in choices.iter().enumerate() {
+ let index = row_index * 3 + column_index;
+ let tile = button::custom(
+ column([])
+ .spacing(6)
.push(visuals::swatches(index))
- .push(self.label(title, 12.)),
+ .push(self.label(preset.name, 12.))
+ .push(self.label(preset.description, 10.)),
)
.width(Length::Fill)
.padding(10)
.class(visuals::button_style(
palette,
visuals::preset_selected(&self.draft, index),
- ))
- .on_press(Message::Preset(index)),
- );
+ ));
+ tiles = tiles.push(if self.saving {
+ tile
+ } else {
+ tile.on_press(Message::Preset(index))
+ });
+ }
+ presets = presets.push(tiles);
}
body = body.push(presets);
}
@@ -970,7 +978,9 @@ impl App {
}
fn load_thumbnail(&mut self) -> Task {
- let path = self.current.string("theme.background.path", "");
+ let path = self
+ .current
+ .string("theme.background.path", ferese_config::default_wallpaper());
if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path {
return Task::none();
}
diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs
index f162a43..d5db414 100644
--- a/shell/ferese-settings/src/schema.rs
+++ b/shell/ferese-settings/src/schema.rs
@@ -558,7 +558,7 @@ pub fn fields(page: Page) -> Vec {
"theme.colors.accent",
"Accent color",
"Focus rings and selected controls. Use a hex color.",
- "#5B8CFF",
+ "#3D7BE6",
),
text(
"theme.colors.surface_base",
@@ -607,7 +607,7 @@ pub fn fields(page: Page) -> Vec {
"theme.background.path",
"Image location",
"Choose an image above, or enter its full path.",
- "",
+ ferese_config::default_wallpaper(),
),
choice(
"theme.background.mode",
diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs
index dad0dfa..dd1f1e1 100644
--- a/shell/ferese-settings/src/store.rs
+++ b/shell/ferese-settings/src/store.rs
@@ -414,7 +414,7 @@ window-rule app-id="mine" floating=#true
}
}
}
- for preset in 0..3 {
+ for preset in 0..crate::visuals::PRESETS.len() {
let mut snapshot = Snapshot::parse(String::new()).unwrap();
for edit in crate::visuals::preset(preset) {
snapshot.edit(&edit).unwrap();
diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs
index 5bafbb2..34d1fcf 100644
--- a/shell/ferese-settings/src/visuals.rs
+++ b/shell/ferese-settings/src/visuals.rs
@@ -42,19 +42,19 @@ impl Palette {
Color::from_rgb8(15, 16, 20),
);
Self {
- background: mix(base, Color::WHITE, 0.025),
+ background: mix(base, surface_shade(base), 0.025),
sidebar: base,
- card: mix(base, Color::WHITE, 0.055),
+ card: mix(base, surface_shade(base), 0.055),
accent: color(
- &snapshot.string("theme.colors.accent", "#5B8CFF"),
- Color::from_rgb8(229, 200, 144),
+ &snapshot.string("theme.colors.accent", "#3D7BE6"),
+ Color::from_rgb8(61, 123, 230),
),
text: color(
- &snapshot.string("theme.colors.text_primary", "#e2e0e6"),
+ &snapshot.string("theme.colors.text_primary", "#F4F7FB"),
Color::WHITE,
),
muted: color(
- &snapshot.string("theme.colors.text_muted", "#96959f"),
+ &snapshot.string("theme.colors.text_muted", "#8793A2"),
Color::from_rgb8(150, 149, 159),
),
error: mix(base, Color::from_rgb8(210, 80, 80), 0.2),
@@ -62,6 +62,29 @@ impl Palette {
}
}
+fn luminance(color: Color) -> f32 {
+ let channel = |value: f32| {
+ if value <= 0.04045 {
+ value / 12.92
+ } else {
+ ((value + 0.055) / 1.055).powf(2.4)
+ }
+ };
+ 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b)
+}
+
+fn surface_shade(base: Color) -> Color {
+ if luminance(base) > 0.5 {
+ Color::BLACK
+ } else {
+ Color::WHITE
+ }
+}
+
+fn cosmic_color(color: Color) -> cosmic::theme::CosmicColor {
+ cosmic::theme::CosmicColor::new(color.r, color.g, color.b, 1.)
+}
+
fn mix(a: Color, b: Color, t: f32) -> Color {
Color::from_rgb(
a.r + (b.r - a.r) * t,
@@ -126,17 +149,21 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
background: Some(Background::Color(if selected {
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
} else if hover {
- mix(p.card, Color::WHITE, 0.06)
+ mix(p.card, surface_shade(p.sidebar), 0.06)
} else if navigation {
p.sidebar
} else {
p.card
})),
- text_color: Some(if selected { p.accent } else { p.text }),
+ text_color: Some(p.text),
icon_color: None,
border_radius: 9.into(),
- border_width: 0.,
- border_color: Color::TRANSPARENT,
+ border_width: if selected { 1. } else { 0. },
+ border_color: if selected {
+ p.accent
+ } else {
+ Color::TRANSPARENT
+ },
outline_width: 0.,
outline_color: Color::TRANSPARENT,
overlay: None,
@@ -152,14 +179,21 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
}
pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
- let mut theme = cosmic::theme::COSMIC_DARK.clone();
- if let Some(snapshot) = snapshot {
- let p = Palette::from(snapshot);
- let accent = cosmic::theme::CosmicColor::new(p.accent.r, p.accent.g, p.accent.b, 1.);
- theme.accent.base = accent;
- theme.accent_button.base = accent;
- theme.text_button.on = accent;
- }
+ let Some(snapshot) = snapshot else {
+ return cosmic::Theme::custom(std::sync::Arc::new(cosmic::theme::COSMIC_DARK.clone()));
+ };
+ let palette = Palette::from(snapshot);
+ let builder = if luminance(palette.sidebar) > 0.5 {
+ cosmic::cosmic_theme::ThemeBuilder::light()
+ } else {
+ cosmic::cosmic_theme::ThemeBuilder::dark()
+ };
+ let theme = builder
+ .bg_color(cosmic_color(palette.background))
+ .primary_container_bg(cosmic_color(palette.card))
+ .text_tint(cosmic_color(palette.text).color)
+ .accent(cosmic_color(palette.accent).color)
+ .build();
cosmic::Theme::custom(std::sync::Arc::new(theme))
}
@@ -181,7 +215,18 @@ pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
}
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
- svg_icon::from_svg_bytes(format!(r##""##, page.icon(),hex(tint)).into_bytes()).symbolic(false).icon().size(19)
+ let svg = format!(
+ r##""##,
+ page.icon(),
+ hex(tint),
+ );
+ svg_icon::from_svg_bytes(svg.into_bytes())
+ .symbolic(false)
+ .icon()
+ .size(19)
}
pub fn brand_icon() -> svg_icon::Icon {
@@ -192,51 +237,149 @@ pub fn brand_icon() -> svg_icon::Icon {
}
pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
- svg_icon::from_svg_bytes(format!(r##""##, hex(tint)).into_bytes())
+ let svg = format!(
+ r##""##,
+ hex(tint),
+ );
+ svg_icon::from_svg_bytes(svg.into_bytes())
.symbolic(false)
.icon()
.size(16)
}
-const PRESETS: [(&str, &str, &str, &str, &str); 3] = [
- ("#e5c890", "#0f1014", "#c9c7cd", "#9998a8", "#795e40"),
- ("#e5e5e5", "#101012", "#ededf0", "#97979f", "#696973"),
- ("#cba6f7", "#1e1e2e", "#cdd6f4", "#a6adc8", "#89b4fa"),
+
+pub struct Preset {
+ pub name: &'static str,
+ pub description: &'static str,
+ accent: &'static str,
+ base: &'static str,
+ text: &'static str,
+ muted: &'static str,
+ border: &'static str,
+ gradient_end: &'static str,
+}
+
+pub const PRESETS: [Preset; 6] = [
+ Preset {
+ name: "Ferese Blue",
+ description: "Default · logo blue",
+ accent: "#3D7BE6",
+ base: "#111821",
+ text: "#F4F7FB",
+ muted: "#8793A2",
+ border: "#FFFFFF18",
+ gradient_end: "#3D7BE6",
+ },
+ Preset {
+ name: "Monochrome",
+ description: "Dark · grayscale",
+ accent: "#E5E5E5",
+ base: "#101012",
+ text: "#EDEDF0",
+ muted: "#97979F",
+ border: "#FFFFFF18",
+ gradient_end: "#696973",
+ },
+ Preset {
+ name: "Gruvbox",
+ description: "Dark · warm orange",
+ accent: "#FE8019",
+ base: "#282828",
+ text: "#EBDBB2",
+ muted: "#BDAE93",
+ border: "#504945",
+ gradient_end: "#FABD2F",
+ },
+ Preset {
+ name: "Dracula",
+ description: "Dark · purple",
+ accent: "#BD93F9",
+ base: "#282A36",
+ text: "#F8F8F2",
+ muted: "#A4ADCD",
+ border: "#44475A",
+ gradient_end: "#FF79C6",
+ },
+ Preset {
+ name: "Ayu Light",
+ description: "Light · paper",
+ accent: "#8F5300",
+ base: "#F8F9FA",
+ text: "#5C6166",
+ muted: "#596574",
+ border: "#C8CDD3",
+ gradient_end: "#996000",
+ },
+ Preset {
+ name: "Monokai",
+ description: "Dark · warm green",
+ accent: "#A6E22E",
+ base: "#272822",
+ text: "#F8F8F2",
+ muted: "#B2B29F",
+ border: "#414339",
+ gradient_end: "#E6DB74",
+ },
];
+
pub fn preset(index: usize) -> Vec {
- let (accent, base, text, muted, end) = PRESETS[index.min(2)];
+ let Some(preset) = PRESETS.get(index) else {
+ return Vec::new();
+ };
vec![
- set("theme.colors.accent", accent),
- set("theme.colors.surface_base", base),
- set("theme.colors.text_primary", text),
- set("theme.colors.text_muted", muted),
- set("theme.surface.bar.background", base),
- set("theme.surface.bar.text_primary", text),
- set("theme.surface.bar.text_muted", muted),
- set("theme.focus_ring.gradient.from", accent),
- set("theme.focus_ring.gradient.to", end),
+ set("theme.colors.accent", preset.accent),
+ set("theme.colors.surface_base", preset.base),
+ set("theme.colors.text_primary", preset.text),
+ set("theme.colors.text_muted", preset.muted),
+ set("theme.colors.border", preset.border),
+ set("theme.colors.shadow", "#00000055"),
+ set("theme.surface.bar.background", preset.base),
+ set("theme.surface.bar.text_primary", preset.text),
+ set("theme.surface.bar.text_muted", preset.muted),
+ set("theme.focus_ring.gradient.from", preset.accent),
+ set("theme.focus_ring.gradient.to", preset.gradient_end),
+ set("theme.focus_ring.gradient.angle", 0.0),
]
}
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
- let (accent, base, text, muted, _) = PRESETS[index];
+ let Some(preset) = PRESETS.get(index) else {
+ return false;
+ };
[
- ("accent", accent),
- ("surface_base", base),
- ("text_primary", text),
- ("text_muted", muted),
+ ("theme.colors.accent", preset.accent, "#3D7BE6"),
+ ("theme.colors.surface_base", preset.base, "#111821"),
+ ("theme.colors.text_primary", preset.text, "#F4F7FB"),
+ ("theme.colors.text_muted", preset.muted, "#8793A2"),
+ ("theme.colors.border", preset.border, "#FFFFFF18"),
+ ("theme.colors.shadow", "#00000055", "#00000055"),
+ ("theme.surface.bar.background", preset.base, "#1C202EF2"),
+ ("theme.surface.bar.text_primary", preset.text, "#F0F3FA"),
+ ("theme.surface.bar.text_muted", preset.muted, "#AAB4C7"),
+ ("theme.focus_ring.gradient.from", preset.accent, "#3D7BE6"),
+ (
+ "theme.focus_ring.gradient.to",
+ preset.gradient_end,
+ "#3D7BE6",
+ ),
]
.into_iter()
- .all(|(key, value)| {
+ .all(|(path, expected, fallback)| {
+ if index == 0 && snapshot.item(path).is_none() {
+ return true;
+ }
snapshot
- .string(&format!("theme.colors.{key}"), "")
- .eq_ignore_ascii_case(value)
- })
+ .string(path, fallback)
+ .eq_ignore_ascii_case(expected)
+ }) && snapshot.number("theme.focus_ring.gradient.angle", 0.) == 0.
}
pub fn swatches(index: usize) -> Element<'static, Message> {
- let (accent, base, _, _, end) = PRESETS[index];
+ let preset = &PRESETS[index];
let mut row = widget::row([]).spacing(3);
- for value in [base, end, accent] {
+ for value in [preset.base, preset.gradient_end, preset.accent] {
row = row.push(
container(widget::Space::new().width(12).height(22))
.class(surface(color(value, Color::WHITE), 4.)),
@@ -244,8 +387,7 @@ pub fn swatches(index: usize) -> Element<'static, Message> {
}
row.into()
}
-// A deliberately stylized preview, built as vector geometry at the actual
-// output scale. No full-resolution wallpaper buffers or screenshot polling.
+
pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
let p = Palette::from(snapshot);
let base = hex(p.sidebar);
@@ -291,3 +433,82 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
.content_fit(cosmic::iced::ContentFit::Contain)
.into()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn configured_preset(index: usize) -> Snapshot {
+ let mut snapshot = Snapshot::parse(String::new()).unwrap();
+ for edit in preset(index) {
+ snapshot.edit(&edit).unwrap();
+ }
+ snapshot
+ }
+
+ fn contrast(a: Color, b: Color) -> f32 {
+ let a = luminance(a);
+ let b = luminance(b);
+ (a.max(b) + 0.05) / (a.min(b) + 0.05)
+ }
+
+ #[test]
+ fn presets_are_distinct_and_readable_on_settings_surfaces() {
+ for (index, preset) in PRESETS.iter().enumerate() {
+ let snapshot = configured_preset(index);
+ let palette = Palette::from(&snapshot);
+ for background in [palette.background, palette.sidebar, palette.card] {
+ assert!(
+ contrast(palette.text, background) >= 4.5,
+ "{} text",
+ preset.name
+ );
+ assert!(
+ contrast(palette.muted, background) >= 4.5,
+ "{} muted text",
+ preset.name
+ );
+ }
+ for other in PRESETS.iter().skip(index + 1) {
+ assert_ne!(preset.accent, other.accent);
+ assert_ne!(preset.base, other.base);
+ }
+ }
+ }
+
+ #[test]
+ fn selection_tracks_all_preset_colors_and_gradient_changes() {
+ for index in 0..PRESETS.len() {
+ let mut snapshot = configured_preset(index);
+ assert!(preset_selected(&snapshot, index));
+ snapshot
+ .edit(&set("theme.surface.bar.text_primary", "#123456"))
+ .unwrap();
+ assert!(!preset_selected(&snapshot, index));
+ let mut snapshot = configured_preset(index);
+ snapshot
+ .edit(&set("theme.focus_ring.gradient.to", "#123456"))
+ .unwrap();
+ assert!(!preset_selected(&snapshot, index));
+ }
+ assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
+ assert!(preset(usize::MAX).is_empty());
+ assert!(!preset_selected(
+ &Snapshot::parse(String::new()).unwrap(),
+ usize::MAX
+ ));
+ }
+
+ #[test]
+ fn default_uses_logo_blue_and_native_controls_follow_lightness() {
+ let logo = include_str!("../../../packaging/icons/ferese.svg").to_ascii_uppercase();
+ assert!(logo.contains(PRESETS[0].accent));
+ for index in 0..PRESETS.len() {
+ let snapshot = configured_preset(index);
+ assert_eq!(
+ native_theme(Some(&snapshot)).cosmic().is_dark,
+ PRESETS[index].name != "Ayu Light"
+ );
+ }
+ }
+}
diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs
index 2131155..1eb4c4f 100644
--- a/shell/ferese-shell/src/config.rs
+++ b/shell/ferese-shell/src/config.rs
@@ -67,8 +67,8 @@ impl Default for ShellTheme {
surface_base: [17, 24, 33, 255],
surface_popover: [17, 24, 33, 245],
text_primary: [244, 247, 251, 255],
- text_muted: [127, 138, 152, 255],
- accent: [91, 140, 255, 255],
+ text_muted: [135, 147, 162, 255],
+ accent: [61, 123, 230, 255],
border: [255, 255, 255, 24],
shadow: [0, 0, 0, 85],
bar_height: 28.0,
@@ -95,13 +95,27 @@ impl ShellTheme {
}
}
-#[derive(Clone, Debug, Default, Deserialize)]
+#[derive(Clone, Debug, Deserialize)]
pub(crate) struct WallpaperConfig {
+ #[serde(default = "default_wallpaper_path")]
pub(crate) path: Option,
#[serde(default)]
pub(crate) mode: WallpaperMode,
}
+fn default_wallpaper_path() -> Option {
+ Some(PathBuf::from(ferese_config::default_wallpaper()))
+}
+
+impl Default for WallpaperConfig {
+ fn default() -> Self {
+ Self {
+ path: default_wallpaper_path(),
+ mode: WallpaperMode::default(),
+ }
+ }
+}
+
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WallpaperMode {
@@ -423,11 +437,11 @@ fn default_text_primary() -> String {
}
fn default_text_muted() -> String {
- "#7F8A98".to_owned()
+ "#8793A2".to_owned()
}
fn default_accent() -> String {
- "#5B8CFF".to_owned()
+ "#3D7BE6".to_owned()
}
fn default_border() -> String {
@@ -555,7 +569,10 @@ theme {
let config: FereseConfig = ferese_config::from_str("").unwrap();
assert_eq!(config.theme.typography.font_family, None);
- assert_eq!(config.theme.background.path, None);
+ assert_eq!(
+ config.theme.background.path,
+ Some(PathBuf::from(ferese_config::default_wallpaper()))
+ );
assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_margin_top, 0);