From d0975631cfa533f163ce121490cf78b04b2070d9 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 08:10:29 +0100 Subject: [PATCH] add shell typography and wallpaper support --- Cargo.lock | 2 + docs/ferese-theme-ui-spec.md | 17 ++++- shell/ferese-shell/Cargo.toml | 2 + shell/ferese-shell/src/config.rs | 124 +++++++++++++++++++++++++++++++ shell/ferese-shell/src/main.rs | 96 ++++++++++++++++++++---- 5 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 shell/ferese-shell/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 20fd399..27450fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1644,6 +1644,8 @@ dependencies = [ "ferese-protocols", "jiff", "libcosmic", + "serde", + "toml 0.9.12+spec-1.1.0", "wayland-client", ] diff --git a/docs/ferese-theme-ui-spec.md b/docs/ferese-theme-ui-spec.md index 41cdb12..cc29f33 100644 --- a/docs/ferese-theme-ui-spec.md +++ b/docs/ferese-theme-ui-spec.md @@ -132,6 +132,12 @@ image fill image fit ``` +```toml +[theme.background] +path = "/path/to/wallpaper.png" +mode = "fill" # fill or fit +``` + Ferese SHOULD NOT globally blur the wallpaper. Optional restrained adjustments MAY include: @@ -235,7 +241,7 @@ base_unit = 8.0 window_radius = 14.0 top_bar_height = 32.0 -top_bar_margin_top = 8.0 +top_bar_margin_top = 4.0 top_bar_margin_horizontal = 10.0 top_bar_radius = 16.0 @@ -256,6 +262,11 @@ Only the top bar consumes persistent shell space. ## 10. Typography +```toml +[theme.typography] +font_family = "sans-serif" +``` + Initial roles: ```text @@ -269,6 +280,10 @@ display 22-28 px semibold Use the system UI sans-serif by default. +The configured family applies consistently to shell text. Icon assets use the +theme icon set and are sized from the same scale tokens rather than text glyph +metrics. + Clocks and changing numeric values SHOULD use tabular numerals where available. ## 11. Iconography diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 1d659cc..13efd1a 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -9,4 +9,6 @@ rust-version = "1.93" ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } jiff.workspace = true libcosmic.workspace = true +serde.workspace = true +toml.workspace = true wayland-client.workspace = true diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs new file mode 100644 index 0000000..cdc1b2a --- /dev/null +++ b/shell/ferese-shell/src/config.rs @@ -0,0 +1,124 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +use serde::Deserialize; + +const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; + +#[derive(Clone, Debug, Default)] +pub(crate) struct ShellConfig { + pub(crate) font_family: Option, + pub(crate) wallpaper: WallpaperConfig, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub(crate) struct WallpaperConfig { + pub(crate) path: Option, + #[serde(default)] + pub(crate) mode: WallpaperMode, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WallpaperMode { + #[default] + Fill, + Fit, +} + +#[derive(Debug, Default, Deserialize)] +struct FereseConfig { + #[serde(default)] + theme: ThemeConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct ThemeConfig { + #[serde(default)] + typography: TypographyConfig, + #[serde(default)] + background: WallpaperConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct TypographyConfig { + font_family: Option, +} + +pub(crate) fn load() -> ShellConfig { + let Some(path) = config_path() else { + return ShellConfig::default(); + }; + let Ok(source) = fs::read_to_string(&path) else { + return ShellConfig::default(); + }; + + match toml::from_str::(&source) { + Ok(config) => ShellConfig { + font_family: config.theme.typography.font_family, + wallpaper: config.theme.background, + }, + Err(error) => { + eprintln!("ferese-shell: failed to read {}: {error}", path.display()); + ShellConfig::default() + } + } +} + +pub(crate) const fn default_background() -> [u8; 3] { + DEFAULT_BACKGROUND +} + +fn config_path() -> Option { + if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { + return Some(PathBuf::from(directory).join("ferese/config.toml")); + } + + env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|home| home.join(".config/ferese/config.toml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_theme_without_rejecting_compositor_sections() { + let config: FereseConfig = toml::from_str( + r#" + [layout] + mode = "scrolling" + + [theme.typography] + font_family = "JetBrainsMono Nerd Font" + + [theme.background] + path = "/tmp/wallpaper.png" + mode = "fit" + "#, + ) + .unwrap(); + + assert_eq!( + config.theme.typography.font_family.as_deref(), + Some("JetBrainsMono Nerd Font") + ); + assert_eq!( + config.theme.background.path, + Some(PathBuf::from("/tmp/wallpaper.png")) + ); + assert_eq!(config.theme.background.mode, WallpaperMode::Fit); + } + + #[test] + fn defaults_to_the_ferese_visual_profile() { + let config: FereseConfig = toml::from_str("").unwrap(); + + assert_eq!(config.theme.typography.font_family, None); + assert_eq!(config.theme.background.path, None); + assert_eq!(config.theme.background.mode, WallpaperMode::Fill); + } +} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 7a327be..5dbbdcf 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1,3 +1,4 @@ +mod config; mod control; use std::time::Duration; @@ -11,10 +12,11 @@ use cosmic::iced::platform_specific::{ shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, }; use cosmic::iced::{ - Background, Border, Color, Event, Length, Shadow, Subscription, Vector, window, + Background, Border, Color, ContentFit, Event, Length, Limits, Shadow, Subscription, Vector, + window, }; use cosmic::theme; -use cosmic::widget::{button, container, row, text}; +use cosmic::widget::{button, container, image, row, text}; use ferese_protocols::effects::v1::client::{ ferese_effects_manager_v1::FereseEffectsManagerV1, ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1}, @@ -26,11 +28,12 @@ use wayland_client::{ protocol::{wl_registry, wl_surface}, }; +use crate::config::{ShellConfig, WallpaperMode}; use crate::control::{ShellControl, ShellSnapshot}; const APP_ID: &str = "dev.ferese.Shell"; const BAR_HEIGHT: u32 = 32; -const BAR_MARGIN: i32 = 8; +const BAR_MARGIN: i32 = 4; const EXCLUSIVE_ZONE: i32 = BAR_HEIGHT as i32; const BAR_TEXT_SIZE: u16 = (BAR_HEIGHT * 3 / 8) as u16; const BAR_ICON_SIZE: u16 = (BAR_HEIGHT / 2) as u16; @@ -42,18 +45,25 @@ const DOT_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.1875; const EMPTY_MARKER_SIZE: f32 = BAR_HEIGHT as f32 * 0.125; fn main() -> cosmic::iced::Result { - let settings = Settings::default() + let config = config::load(); + let mut settings = Settings::default() .no_main_window(true) .client_decorations(false) .transparent(true) .is_daemon(true); + if let Some(font_family) = config.font_family.clone() { + let font_family = Box::leak(font_family.into_boxed_str()); - cosmic::app::run::(settings, ()) + settings = settings.default_font(cosmic::font::Font::with_name(font_family)); + } + + cosmic::app::run::(settings, config) } struct FereseShell { core: Core, - surface_id: window::Id, + bar_surface_id: window::Id, + config: ShellConfig, control: Option, snapshot: ShellSnapshot, clock: String, @@ -70,7 +80,7 @@ enum Message { impl cosmic::Application for FereseShell { type Executor = cosmic::executor::Default; - type Flags = (); + type Flags = ShellConfig; type Message = Message; const APP_ID: &'static str = APP_ID; @@ -83,11 +93,13 @@ impl cosmic::Application for FereseShell { &mut self.core } - fn init(core: Core, _flags: Self::Flags) -> (Self, Task) { - let surface_id = window::Id::unique(); + fn init(core: Core, config: Self::Flags) -> (Self, Task) { + let bar_surface_id = window::Id::unique(); + let wallpaper_surface_id = window::Id::unique(); let app = Self { core, - surface_id, + bar_surface_id, + config, control: ShellControl::connect() .map_err(|error| { eprintln!("ferese-shell: shell control unavailable: {error}"); @@ -97,10 +109,26 @@ impl cosmic::Application for FereseShell { clock: current_time(), effects: None, }; - let action = cosmic::surface::action::app_layer_shell::( + let wallpaper_action = cosmic::surface::action::app_layer_shell::( |_| Default::default(), move |_| SctkLayerSurfaceSettings { - id: surface_id, + id: wallpaper_surface_id, + layer: Layer::Background, + keyboard_interactivity: KeyboardInteractivity::None, + input_zone: Some(Vec::new()), + anchor: Anchor::TOP | Anchor::RIGHT | Anchor::BOTTOM | Anchor::LEFT, + output: IcedOutput::Active, + namespace: "ferese-shell-wallpaper".to_owned(), + size: Some((None, None)), + size_limits: Limits::NONE, + ..Default::default() + }, + Some(Box::new(Self::view_wallpaper)), + ); + let bar_action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id: bar_surface_id, layer: Layer::Top, keyboard_interactivity: KeyboardInteractivity::None, anchor: Anchor::TOP | Anchor::LEFT | Anchor::RIGHT, @@ -113,13 +141,18 @@ impl cosmic::Application for FereseShell { left: 10, }, size: Some((None, Some(BAR_HEIGHT))), + size_limits: Limits::NONE, exclusive_zone: EXCLUSIVE_ZONE, ..Default::default() }, Some(Box::new(Self::view_layer)), ); - (app, cosmic::task::message(cosmic::Action::Surface(action))) + let tasks = [wallpaper_action, bar_action] + .map(cosmic::Action::Surface) + .map(cosmic::task::message); + + (app, Task::batch(tasks)) } fn subscription(&self) -> Subscription { @@ -173,12 +206,14 @@ impl cosmic::Application for FereseShell { impl FereseShell { fn handle_event(&mut self, event: Event, id: window::Id) -> Task { match event { - Event::Window(window::Event::Closed) if id == self.surface_id => cosmic::iced::exit(), + Event::Window(window::Event::Closed) if id == self.bar_surface_id => { + cosmic::iced::exit() + } Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( _, surface, frame_id, - ))) if frame_id == self.surface_id => { + ))) if frame_id == self.bar_surface_id => { self.attach_effects(&surface); Task::none() } @@ -186,7 +221,7 @@ impl FereseShell { _, surface, layer_id, - ))) if layer_id == self.surface_id => { + ))) if layer_id == self.bar_surface_id => { self.attach_effects(&surface); Task::none() } @@ -290,6 +325,26 @@ impl FereseShell { .class(theme::Container::custom(bar_style)) .into() } + + fn view_wallpaper(&self) -> Element<'_, cosmic::Action> { + let Some(path) = self.config.wallpaper.path.as_ref() else { + return container(text("")) + .width(Length::Fill) + .height(Length::Fill) + .class(theme::Container::custom(wallpaper_fallback_style)) + .into(); + }; + let content_fit = match self.config.wallpaper.mode { + WallpaperMode::Fill => ContentFit::Cover, + WallpaperMode::Fit => ContentFit::Contain, + }; + + image(path.clone()) + .width(Length::Fill) + .height(Length::Fill) + .content_fit(content_fit) + .into() + } } fn workspace_indicator(active: bool, occupied: bool) -> Element<'static, cosmic::Action> { @@ -390,6 +445,15 @@ fn bar_style(_theme: &cosmic::Theme) -> container::Style { } } +fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style { + let [red, green, blue] = config::default_background(); + + container::Style { + background: Some(Background::Color(Color::from_rgb8(red, green, blue))), + ..container::Style::default() + } +} + struct EffectsBinding { _manager: FereseEffectsManagerV1, _surface: FereseSurfaceEffectsV1,