add shell typography and wallpaper support

This commit is contained in:
Samuel Onoja committed 2026-09-25 08:10:29 +01:00
1 parent dc3ef13501
commit d0975631cf
5 files changed
+224 -17

No files matched your search

Generated
+2
View File
@@ -1644,6 +1644,8 @@ dependencies = [
"ferese-protocols",
"jiff",
"libcosmic",
"serde",
"toml 0.9.12+spec-1.1.0",
"wayland-client",
]
+16 -1
View File
@@ -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
+2
View File
@@ -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
+124
View File
@@ -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<String>,
pub(crate) wallpaper: WallpaperConfig,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub(crate) struct WallpaperConfig {
pub(crate) path: Option<PathBuf>,
#[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<String>,
}
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::<FereseConfig>(&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<PathBuf> {
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);
}
}
+80 -16
View File
@@ -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::<FereseShell>(settings, ())
settings = settings.default_font(cosmic::font::Font::with_name(font_family));
}
cosmic::app::run::<FereseShell>(settings, config)
}
struct FereseShell {
core: Core,
surface_id: window::Id,
bar_surface_id: window::Id,
config: ShellConfig,
control: Option<ShellControl>,
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<Self::Message>) {
let surface_id = window::Id::unique();
fn init(core: Core, config: Self::Flags) -> (Self, Task<Self::Message>) {
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::<Self>(
let wallpaper_action = cosmic::surface::action::app_layer_shell::<Self>(
|_| 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::<Self>(
|_| 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<Self::Message> {
@@ -173,12 +206,14 @@ impl cosmic::Application for FereseShell {
impl FereseShell {
fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
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<Message>> {
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<Message>> {
@@ -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,