Share native theme components and fix dialog backdrop blur

Centralize native palettes, presets, typography, controls, menus, calendar rendering, and icon assets in ferese-theme. Reuse the shared components across the shell, settings, lock screen, authentication prompt, and screen-sharing chooser.

Attach themed dialogs to compositor-owned materials, correct backdrop capture and sampling across output transforms, smooth patterned backgrounds, and prevent clipped picker buttons with the configured shell font.
This commit is contained in:
Samuel Onoja committed 2026-09-29 21:19:19 +01:00
1 parent 362bf79012
commit b2363a18b0
85 files changed
+2163 -1591

No files matched your search

+16
View File
@@ -90,6 +90,22 @@ impl Default for ShellTheme {
}
impl ShellTheme {
pub(crate) fn palette(self) -> ferese_theme::Palette {
let color =
|[r, g, b, a]: [u8; 4]| cosmic::iced::Color::from_rgba8(r, g, b, f32::from(a) / 255.);
let surface = color(self.surface_base);
ferese_theme::Palette {
background: surface,
sidebar: surface,
card: surface,
text: color(self.text_primary),
muted: color(self.text_muted),
accent: color(self.accent),
radius: self.material_radius,
error: cosmic::iced::Color::from_rgb8(235, 98, 98),
}
}
// The bar has its own foreground tokens, matched to its surface.
pub(crate) fn for_bar(mut self) -> Self {
self.text_primary = self.bar_text_primary;
+30 -217
View File
@@ -39,6 +39,9 @@ use wayland_client::{
use crate::config::{ShellConfig, ShellTheme, WallpaperMode};
use crate::control::{ShellControl, ShellSnapshot};
use ferese_theme::calendar::format_bar_time;
use ferese_theme::calendar::stacked_digits as stacked_clock_digits;
use ferese_theme::icons::{accented as accented_icon, tinted as bar_icon};
const APP_ID: &str = "dev.ferese.Shell";
@@ -50,40 +53,20 @@ static SHELL_FONT: std::sync::OnceLock<std::sync::RwLock<cosmic::font::Font>> =
std::sync::OnceLock::new();
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
family.map_or_else(cosmic::font::default, |family| {
// Font names require static storage. Intern repeated families and cap
// the cache so live theme edits cannot leak unlimited strings.
static FONTS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
> = std::sync::OnceLock::new();
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
ferese_theme::font(family)
}
if let Some(font) = fonts.get(family) {
return *font;
}
if fonts.len() >= 64 {
return cosmic::font::default();
}
let family = Box::leak(family.to_owned().into_boxed_str());
let font = cosmic::font::Font::with_name(family);
fonts.insert(family.to_owned(), font);
font
})
fn shell_font() -> cosmic::font::Font {
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
.read()
.unwrap()
}
// COSMIC's text helper explicitly selects its interface font, overriding
// Settings::default_font. Use this helper for every bar and menu label.
fn text<'a>(
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
cosmic::widget::text(content).font(
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
.read()
.unwrap(),
)
ferese_theme::text(content, shell_font())
}
#[derive(Clone, Copy)]
@@ -111,35 +94,6 @@ impl From<ShellTheme> for BarMetrics {
}
}
fn stacked_clock_digits(label: &str) -> Option<[String; 2]> {
let mut parts = label.split_whitespace();
let time = parts.next()?;
if let Some(period) = parts.next()
&& !period.eq_ignore_ascii_case("am")
&& !period.eq_ignore_ascii_case("pm")
{
return None;
}
if parts.next().is_some() {
return None;
}
let (hour, minute) = time.split_once(':')?;
if !(1..=2).contains(&hour.len())
|| minute.len() != 2
|| !hour
.bytes()
.chain(minute.bytes())
.all(|digit| digit.is_ascii_digit())
{
return None;
}
Some([format!("{hour:0>2}"), minute.to_owned()])
}
fn main() -> cosmic::iced::Result {
// Small shell surfaces do not need a second GPU device and shader setup.
// Keep wgpu as a fallback and honor an explicit renderer preference.
@@ -191,7 +145,9 @@ fn main() -> cosmic::iced::Result {
.client_decorations(false)
.transparent(true)
.is_daemon(true);
settings = settings.default_font(shell_font);
settings = settings
.default_font(shell_font)
.theme(config.theme.palette().native_theme());
cosmic::app::run::<FereseShell>(settings, (config, wallpaper))
}
@@ -884,6 +840,10 @@ impl FereseShell {
Task::none()
}];
if old.palette() != theme.palette() {
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
}
if clock_changed
&& self
.note_drag
@@ -2287,55 +2247,12 @@ impl FereseShell {
}
}
fn bar_icon(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon {
accented_icon(source, size, foreground, foreground)
}
fn accented_icon(source: &'static [u8], size: u16, foreground: Color, accent: Color) -> icon::Icon {
let svg = std::str::from_utf8(source).expect("embedded SVG must be UTF-8");
// Battery canvases are wider; reserve that space instead of stretching
// or clipping them into the square slot used by the other status icons.
let aspect = if svg.contains("viewBox=\"0 0 32 24\"") {
4.0 / 3.0
} else {
1.0
};
icon::from_svg_bytes(tinted_svg(source, foreground, accent))
.symbolic(false)
.icon()
.size(size)
.width(Length::Fixed(f32::from(size) * aspect))
.content_fit(ContentFit::Contain)
.class(theme::Svg::custom(move |_| {
cosmic::iced::widget::svg::Style { color: None }
}))
}
fn tinted_svg(source: &[u8], foreground: Color, accent: Color) -> Vec<u8> {
let rgba = |foreground: Color| {
format!(
"rgba({},{},{},{})",
(foreground.r * 255.0).round() as u8,
(foreground.g * 255.0).round() as u8,
(foreground.b * 255.0).round() as u8,
foreground.a,
)
};
std::str::from_utf8(source)
.expect("embedded icons must be UTF-8 SVG")
.replace("currentColor", &rgba(foreground))
// The source artwork's blue swatch is the semantic theme accent.
// Battery warning/success swatches remain status colors.
.replace("#3d7be6", &rgba(accent))
.into_bytes()
}
fn overview_control(
bar: BarMetrics,
foreground: Color,
) -> Element<'static, cosmic::Action<Message>> {
container(bar_icon(
include_bytes!("../assets/icons/ferese.svg"),
ferese_theme::icons::FERESE,
bar.overview_icon_size,
foreground,
))
@@ -2587,48 +2504,20 @@ fn current_time() -> String {
format_bar_time(&Zoned::now())
}
fn format_bar_time(now: &Zoned) -> String {
let months = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sept", "oct", "nov", "dec",
];
let hour = now.hour();
let hour12 = if hour % 12 == 0 { 12 } else { hour % 12 };
format!(
"{} {}, {}:{:02} {}",
now.day(),
months[(now.month() - 1) as usize],
hour12,
now.minute(),
if hour < 12 { "am" } else { "pm" }
)
}
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
cosmic::iced::theme::Style {
background_color: Color::TRANSPARENT,
text_color: color(theme.text_primary),
icon_color: color(theme.text_primary),
}
theme.palette().application_style(Color::TRANSPARENT)
}
fn bar_style(theme: ShellTheme, compositor_material: bool) -> container::Style {
container::Style {
icon_color: Some(color(theme.text_primary)),
text_color: Some(color(theme.text_primary)),
// The compositor paints the selected panel material behind this surface.
// A client-side fill would cover its blur and tint; use it only as fallback.
background: if compositor_material {
None
} else {
Some(Background::Color(color(theme.bar_background)))
},
border: Border {
radius: theme.bar_radius.into(),
..Border::default()
},
snap: true,
..container::Style::default()
let mut style =
ferese_theme::controls::surface_appearance(color(theme.bar_background), theme.bar_radius);
if compositor_material {
style.background = None;
}
style.text_color = Some(color(theme.text_primary));
style.icon_color = style.text_color;
style.snap = true;
style
}
fn bar_group_style(theme: ShellTheme) -> container::Style {
@@ -2818,6 +2707,8 @@ delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1);
#[cfg(test)]
mod tests {
use super::*;
use crate::control::{OutputSnapshot, WindowSnapshot};
#[test]
fn widget_drag_stops_at_edges_without_tunnelling_and_slides() {
use cosmic::iced::{Point, Rectangle};
@@ -2874,21 +2765,6 @@ mod tests {
);
}
#[test]
fn pixel_clock_stacks_only_hour_and_minute_formats() {
assert_eq!(
super::stacked_clock_digits("2:45 pm"),
Some(["02".into(), "45".into()])
);
assert_eq!(
super::stacked_clock_digits("14:45"),
Some(["14".into(), "45".into()])
);
assert!(super::stacked_clock_digits("14:45:30").is_none());
assert!(super::stacked_clock_digits("Today 14:45").is_none());
assert!(super::stacked_clock_digits("14:45 pm extra").is_none());
}
#[test]
fn note_drag_origin_and_bounds_use_logical_output_coordinates() {
use cosmic::iced::Point;
@@ -2938,28 +2814,6 @@ mod tests {
);
}
#[test]
fn clock_uses_lowercase_date_and_twelve_hour_time() {
for (stamp, expected) in [
(
"2026-09-28T21:32:00+01:00[Africa/Lagos]",
"28 sept, 9:32 pm",
),
(
"2026-09-28T00:05:00+01:00[Africa/Lagos]",
"28 sept, 12:05 am",
),
(
"2026-09-28T12:00:00+01:00[Africa/Lagos]",
"28 sept, 12:00 pm",
),
] {
assert_eq!(super::format_bar_time(&stamp.parse().unwrap()), expected);
}
}
use super::*;
use crate::control::{OutputSnapshot, WindowSnapshot};
#[test]
fn shell_font_respects_configured_family() {
assert_eq!(
@@ -2969,47 +2823,6 @@ mod tests {
assert_eq!(configured_font(None), cosmic::font::default());
}
#[test]
fn icons_use_theme_accent_and_preserve_battery_status_colors() {
for (source, accent) in [
(
include_bytes!("../assets/icons/status/battery-full.svg").as_slice(),
"#63c168",
),
(
include_bytes!("../assets/icons/status/battery-50.svg").as_slice(),
"#3d7be6",
),
(
include_bytes!("../assets/icons/status/battery-25.svg").as_slice(),
"#e0654f",
),
(
include_bytes!("../assets/icons/status/wifi-full.svg").as_slice(),
"#3d7be6",
),
(
include_bytes!("../assets/icons/status/control-center.svg").as_slice(),
"#3d7be6",
),
] {
let svg = String::from_utf8(tinted_svg(
source,
Color::from_rgb8(205, 214, 244),
Color::from_rgb8(203, 166, 247),
))
.unwrap();
assert!(!svg.contains("currentColor"));
assert!(svg.contains("rgba(205,214,244,1)"));
assert!(!svg.contains("#3d7be6"));
assert!(svg.contains(if accent == "#3d7be6" {
"rgba(203,166,247,1)"
} else {
accent
}));
}
}
#[test]
fn workspace_selector_has_distinct_active_paint() {
let theme = ShellTheme::default();
+9 -15
View File
@@ -144,21 +144,15 @@ pub(crate) fn button<'a, M: Clone + 'a>(
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
let active = progress.clone();
let hovered = progress.clone();
let paint = move |progress: f32, pressed: bool| cosmic::widget::button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: radius(14.0).into(),
background: Some(cosmic::iced::Background::Color(cosmic::iced::Color {
a: if pressed {
0.20 * opacity
} else {
((if selected { 0.14 } else { 0.0 })
+ progress * if selected { 0.02 } else { 0.08 })
* opacity
},
..foreground
})),
..Default::default()
let paint = move |progress: f32, pressed: bool| {
ferese_theme::controls::shell_button(
foreground,
selected,
opacity,
radius(14.),
progress,
pressed,
)
};
let content = button
.class(cosmic::theme::Button::Custom {
+15 -27
View File
@@ -82,24 +82,17 @@ fn card_button<'a>(
round: bool,
accessible_name: String,
) -> Element<'a, cosmic::Action<Message>> {
let style = move |active: bool| button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
background: (active || round).then_some(Background::Color(hover)),
border_radius: (if round { motion::radius(11.0) } else { 0.0 }).into(),
..Default::default()
};
button::custom(content)
.name(accessible_name)
.width(if round { Length::Shrink } else { Length::Fill })
.padding(0)
.on_press(cosmic::Action::App(message))
.class(theme::Button::Custom {
active: Box::new(move |_, _| style(false)),
hovered: Box::new(move |_, _| style(true)),
pressed: Box::new(move |_, _| style(true)),
disabled: Box::new(move |_| style(false)),
})
.class(ferese_theme::controls::notification_button(
foreground,
hover,
if round { motion::radius(11.) } else { 0. },
round,
))
.into()
}
@@ -308,12 +301,7 @@ impl FereseShell {
bold.weight = cosmic::iced::font::Weight::Bold;
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty()
{
accented_icon(
include_bytes!("../assets/icons/ferese.svg"),
18,
accent,
accent,
)
accented_icon(ferese_theme::icons::FERESE, 18, accent, accent)
} else if notice.icon.starts_with('/') {
icon::icon(icon::from_path(notice.icon.clone().into())).size(18)
} else {
@@ -403,7 +391,7 @@ impl FereseShell {
.class(theme::Text::Color(muted)),
)
.push(icon_control(
include_bytes!("../assets/icons/status/close.svg"),
ferese_theme::icons::CLOSE,
"Dismiss notification",
if history && count > 1 {
Message::RemoveNotificationGroup(notice.app.clone())
@@ -624,7 +612,7 @@ impl FereseShell {
.align_y(alignment::Vertical::Center)
.push(container(heading).width(Length::Fill))
.push(icon_control(
include_bytes!("../assets/icons/status/close.svg"),
ferese_theme::icons::CLOSE,
"Close notification center",
Message::ToggleNotificationHistory,
muted,
@@ -637,9 +625,9 @@ impl FereseShell {
muted
};
let dnd_icon = if self.notifications.dnd {
include_bytes!("../assets/icons/status/notifications-off.svg").as_slice()
ferese_theme::icons::NOTIFICATIONS_OFF
} else {
include_bytes!("../assets/icons/status/notifications.svg").as_slice()
ferese_theme::icons::NOTIFICATIONS
};
let dnd = row([])
.spacing(10)
@@ -705,7 +693,7 @@ impl FereseShell {
.align_x(alignment::Horizontal::Center)
.push(
container(accented_icon(
include_bytes!("../assets/icons/status/notifications.svg"),
ferese_theme::icons::NOTIFICATIONS,
26,
muted,
accent,
@@ -749,7 +737,7 @@ impl FereseShell {
.width(Length::Fill),
)
.push(icon_control(
include_bytes!("../assets/icons/status/trash.svg"),
ferese_theme::icons::TRASH,
"Clear all notifications",
Message::ClearNotifications,
muted,
@@ -777,7 +765,7 @@ impl FereseShell {
.width(Length::Fill),
)
.push(icon_control(
include_bytes!("../assets/icons/status/chevron-up.svg"),
ferese_theme::icons::CHEVRON_UP,
"Collapse group",
Message::ToggleNotificationGroup(latest.app.clone()),
muted,
@@ -785,7 +773,7 @@ impl FereseShell {
24,
))
.push(icon_control(
include_bytes!("../assets/icons/status/trash.svg"),
ferese_theme::icons::TRASH,
"Clear group",
Message::RemoveNotificationGroup(latest.app.clone()),
muted,
+96 -352
View File
@@ -275,16 +275,10 @@ impl FereseShell {
}
let (source, enabled) = if kind == Menu::Recording {
let source: &'static [u8] = match self.recorder.state {
recording::State::Selecting => {
include_bytes!("../assets/icons/status/record-cancel.svg")
}
recording::State::Recording(_) => {
include_bytes!("../assets/icons/status/record-stop.svg")
}
recording::State::Saving => {
include_bytes!("../assets/icons/status/record-saving.svg")
}
_ => include_bytes!("../assets/icons/status/record.svg"),
recording::State::Selecting => ferese_theme::icons::RECORD_CANCEL,
recording::State::Recording(_) => ferese_theme::icons::RECORD_STOP,
recording::State::Saving => ferese_theme::icons::RECORD_SAVING,
_ => ferese_theme::icons::RECORD,
};
(source, true)
} else {
@@ -420,33 +414,20 @@ impl FereseShell {
let kind = menu.kind;
let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, p);
let badge = container(
let badge = ferese_theme::menus::badge(
accented_icon(
status_icon(kind, &self.status).0,
22,
primary,
color_with_opacity(theme.accent, p),
)
.width(Length::Fixed(22.0)),
)
.width(36)
.height(36)
.center_x(36)
.center_y(36)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(color_with_opacity(
theme.accent,
0.12 * p,
))),
border: Border {
radius: motion::radius(20.0).into(),
..Default::default()
},
..Default::default()
}));
let mut heading = row![badge, text(kind.title()).size(18).width(Length::Fill)]
.spacing(10)
.align_y(Alignment::Center);
.width(Length::Fixed(22.))
.into(),
color(theme.accent),
p,
motion::radius(20.),
);
let mut heading = ferese_theme::menus::heading(badge, kind.title(), shell_font());
if kind == Menu::System
&& self
.config
@@ -458,7 +439,7 @@ impl FereseShell {
{
heading = heading.push(motion::button(
button::custom(accented_icon(
include_bytes!("../assets/icons/status/settings.svg"),
ferese_theme::icons::SETTINGS,
20,
primary,
color_with_opacity(theme.accent, p),
@@ -687,7 +668,7 @@ impl FereseShell {
let brightness = column![
text("Brightness").size(13),
slider_row(
include_bytes!("../assets/icons/status/brightness.svg"),
ferese_theme::icons::BRIGHTNESS,
value,
true,
primary,
@@ -778,24 +759,23 @@ impl FereseShell {
}
if menu.kind == Menu::Battery {
rows = rows.push(text("Power mode").size(13).class(theme::Text::Color(muted)));
rows = rows.push(ferese_theme::menus::section_label(
"Power mode",
shell_font(),
muted,
));
if let Some(profiles) = &self.status.power_profiles {
let mut modes = row::with_capacity(3).spacing(6).width(Length::Fill);
for (index, (label, icon, profile)) in [
(
"Power saver",
include_bytes!("../assets/icons/status/power-saver.svg").as_slice(),
ferese_theme::icons::POWER_SAVER,
"power-saver",
),
(
"Balanced",
include_bytes!("../assets/icons/status/power-balanced.svg").as_slice(),
"balanced",
),
("Balanced", ferese_theme::icons::POWER_BALANCED, "balanced"),
(
"Performance",
include_bytes!("../assets/icons/status/power-performance.svg")
.as_slice(),
ferese_theme::icons::POWER_PERFORMANCE,
"performance",
),
]
@@ -827,19 +807,19 @@ impl FereseShell {
(
self.status.reboot,
"Restart",
include_bytes!("../assets/icons/status/restart.svg").as_slice(),
ferese_theme::icons::RESTART,
Action::Reboot,
),
(
self.status.poweroff,
"Power off",
include_bytes!("../assets/icons/status/power-off.svg").as_slice(),
ferese_theme::icons::POWER_OFF,
Action::Poweroff,
),
(
self.status.suspend,
"Suspend",
include_bytes!("../assets/icons/status/suspend.svg").as_slice(),
ferese_theme::icons::SUSPEND,
Action::Suspend,
),
] {
@@ -849,7 +829,11 @@ impl FereseShell {
}
if self.status.reboot || self.status.poweroff || self.status.suspend {
rows = rows.push(text("Power").size(13).class(theme::Text::Color(muted)));
rows = rows.push(ferese_theme::menus::section_label(
"Power",
shell_font(),
muted,
));
rows = rows.push(power);
}
}
@@ -1008,21 +992,7 @@ fn control_card<'a>(
foreground: Color,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
container(content)
.padding(10)
.width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(Color {
a: 0.045 * opacity,
..foreground
})),
border: Border {
radius: motion::radius(22.0).into(),
..Default::default()
},
..Default::default()
}))
.into()
ferese_theme::menus::section(content, foreground, opacity, motion::radius(22.))
}
fn connection_caption(detail: &str) -> String {
@@ -1101,22 +1071,13 @@ fn connection_control<'a>(
))
.on_press(cosmic::Action::App(Message::Control(action)));
let tile: Element<'_, cosmic::Action<Message>> = if selected {
let paint = move |outline| cosmic::widget::button::Style {
background: Some(Color { a: opacity, ..fill }.into()),
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: motion::radius(14.).into(),
outline_width: outline,
outline_color: foreground,
..Default::default()
};
button
.class(theme::Button::Custom {
active: Box::new(move |focused, _| paint(if focused { 1. } else { 0. })),
hovered: Box::new(move |_, _| paint(1.)),
pressed: Box::new(move |_, _| paint(2.)),
disabled: Box::new(move |_| paint(0.)),
})
.class(ferese_theme::controls::filled_button(
fill,
foreground,
motion::radius(14.),
opacity,
))
.into()
} else {
motion::button(button, foreground, false, opacity)
@@ -1180,98 +1141,24 @@ fn power_tile_action<'a>(
motion::button(btn, foreground, false, opacity)
}
fn calendar_month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize, usize) {
let month_index = i32::from(today.year()) * 12 + i32::from(today.month()) - 1 + offset;
let year = month_index.div_euclid(12) as i16;
let month = (month_index.rem_euclid(12) + 1) as i8;
let first = jiff::civil::Date::new(year, month, 1).expect("calendar month is in range");
let start = usize::try_from(first.weekday().since(jiff::civil::Weekday::Monday)).unwrap_or(0);
let days = usize::try_from(first.days_in_month()).unwrap_or(31);
(first, start, days)
}
fn calendar_grid<'a>(
offset: i32,
theme: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let today = Zoned::now().date();
let months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
let (first, start, days) = calendar_month(today, offset);
let year = first.year();
let month = first.month();
let primary = color_with_opacity(theme.text_primary, opacity);
let muted = color_with_opacity(theme.text_muted, opacity);
let header = row![
text(format!("{} {year}", months[(month - 1) as usize]))
.size(15)
.width(Length::Fill),
menu_button("‹", Message::CalendarMonth(-1), primary, opacity),
menu_button("Today", Message::CalendarToday, primary, opacity),
menu_button("›", Message::CalendarMonth(1), primary, opacity),
]
.align_y(Alignment::Center)
.spacing(2);
let mut grid = column::with_capacity(8).spacing(3).push(header);
let mut weekdays = row::with_capacity(7).spacing(3);
for label in ["M", "T", "W", "T", "F", "S", "S"] {
weekdays = weekdays.push(
container(text(label).size(11).class(theme::Text::Color(muted)))
.width(30)
.center_x(30),
);
}
grid = grid.push(weekdays);
for week in 0..(start + days).div_ceil(7) {
let mut dates = row::with_capacity(7).spacing(3);
for weekday in 0..7 {
let cell = week * 7 + weekday;
let day = cell
.checked_sub(start)
.map(|day| day + 1)
.filter(|day| *day <= days);
let is_today =
year == today.year() && month == today.month() && day == Some(today.day() as usize);
let label = day.map_or(String::new(), |day| day.to_string());
dates = dates.push(
container(text(label).size(12).class(theme::Text::Color(if is_today {
primary
} else {
muted
})))
.width(30)
.height(25)
.center_x(30)
.center_y(25)
.class(theme::Container::custom(move |_| container::Style {
background: is_today.then_some(Background::Color(color_with_opacity(
theme.accent,
0.2 * opacity,
))),
border: Border {
radius: motion::radius(12.0).into(),
..Default::default()
},
..Default::default()
})),
);
}
grid = grid.push(dates);
}
grid.into()
ferese_theme::calendar::grid(
Zoned::now().date(),
offset,
theme.palette(),
shell_font(),
opacity,
[
menu_button("‹", Message::CalendarMonth(-1), primary, opacity),
menu_button("Today", Message::CalendarToday, primary, opacity),
menu_button("›", Message::CalendarMonth(1), primary, opacity),
],
)
}
fn menu_button<'a>(
@@ -1281,9 +1168,7 @@ fn menu_button<'a>(
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
motion::button(
button::custom(text(label).size(13))
.padding([6, 8])
.on_press(cosmic::Action::App(message)),
ferese_theme::menus::button(label, cosmic::Action::App(message), shell_font()),
foreground,
false,
opacity,
@@ -1297,59 +1182,7 @@ fn shell_switch<'a>(
palette: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
let track = if enabled {
color(palette.accent)
} else {
Color {
a: 0.22,
..color(palette.text_primary)
}
};
let thumb = if enabled {
ferese_theme::foreground(track, color(palette.text_primary))
} else {
color(palette.text_primary)
};
let knob = container(text(""))
.width(16)
.height(16)
.class(theme::Container::custom(move |_| container::Style {
background: Some(
Color {
a: opacity,
..thumb
}
.into(),
),
border: Border {
radius: motion::radius(8.).into(),
..Default::default()
},
..Default::default()
}));
let toggle = container(knob)
.width(36)
.height(20)
.padding(2)
.align_x(if enabled {
alignment::Horizontal::Right
} else {
alignment::Horizontal::Left
})
.class(theme::Container::custom(move |_| container::Style {
background: Some(
Color {
a: track.a * opacity,
..track
}
.into(),
),
border: Border {
radius: motion::radius(10.).into(),
..Default::default()
},
..Default::default()
}));
let toggle = ferese_theme::menus::switch(enabled, palette.palette(), opacity);
motion::button(
button::custom(toggle)
.padding(4)
@@ -1368,12 +1201,11 @@ fn toggle_row<'a>(
palette: ShellTheme,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
row![
text(label).width(Length::Fill).size(14),
shell_switch(label, on, action, palette, opacity)
]
.align_y(Alignment::Center)
.into()
ferese_theme::menus::row(
label,
shell_switch(label, on, action, palette, opacity),
shell_font(),
)
}
fn slider_row(
@@ -1384,39 +1216,6 @@ fn slider_row(
icon_accent: Color,
opacity: f32,
) -> Element<'static, cosmic::Action<Message>> {
let style = std::rc::Rc::new(move |_: &cosmic::Theme| {
use cosmic::iced::widget::slider::{Breakpoint, Handle, HandleShape, Rail, Style};
Style {
rail: Rail {
backgrounds: (
foreground.into(),
Color {
a: 0.16 * opacity,
..foreground
}
.into(),
),
width: 4.0,
border: Border {
radius: motion::radius(2.0).into(),
..Default::default()
},
},
handle: Handle {
shape: HandleShape::Rectangle {
width: 10,
height: 10,
border_radius: motion::radius(5.0).into(),
},
background: foreground.into(),
border_width: 0.0,
border_color: Color::TRANSPARENT,
},
breakpoint: Breakpoint {
color: Color::TRANSPARENT,
},
}
});
let control = slider(
if brightness { 1..=100 } else { 0..=100 },
value,
@@ -1430,11 +1229,11 @@ fn slider_row(
)
.width(Length::Fill)
.height(24)
.class(theme::iced::Slider::Custom {
active: style.clone(),
hovered: style.clone(),
dragging: style,
});
.class(ferese_theme::menus::slider(
foreground,
opacity,
motion::radius(5.),
));
row![
accented_icon(source, 18, foreground, icon_accent),
control,
@@ -1479,13 +1278,13 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
fn audio_icon(volume: u8, muted: bool) -> &'static [u8] {
if muted || volume == 0 {
include_bytes!("../assets/icons/status/volume-mute.svg")
ferese_theme::icons::VOLUME_MUTE
} else if volume <= 33 {
include_bytes!("../assets/icons/status/volume-low.svg")
ferese_theme::icons::VOLUME_LOW
} else if volume <= 66 {
include_bytes!("../assets/icons/status/volume-medium.svg")
ferese_theme::icons::VOLUME_MEDIUM
} else {
include_bytes!("../assets/icons/status/volume-high.svg")
ferese_theme::icons::VOLUME_HIGH
}
}
@@ -1493,79 +1292,57 @@ fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
match kind {
Menu::Network => {
let Some(n) = &s.network else {
return (include_bytes!("../assets/icons/status/wifi-off.svg"), false);
return (ferese_theme::icons::WIFI_OFF, false);
};
if !n.enabled || n.connection.is_none() {
(include_bytes!("../assets/icons/status/wifi-off.svg"), false)
(ferese_theme::icons::WIFI_OFF, false)
} else if n.signal <= 33 {
(include_bytes!("../assets/icons/status/wifi-low.svg"), true)
(ferese_theme::icons::WIFI_LOW, true)
} else if n.signal <= 66 {
(
include_bytes!("../assets/icons/status/wifi-medium.svg"),
true,
)
(ferese_theme::icons::WIFI_MEDIUM, true)
} else {
(include_bytes!("../assets/icons/status/wifi-full.svg"), true)
(ferese_theme::icons::WIFI_FULL, true)
}
}
Menu::Bluetooth => match &s.bluetooth {
Some(b) if b.enabled && !b.devices.is_empty() => (
include_bytes!("../assets/icons/status/bluetooth-connected.svg"),
true,
),
Some(b) if b.enabled => (
include_bytes!("../assets/icons/status/bluetooth-on.svg"),
true,
),
_ => (
include_bytes!("../assets/icons/status/bluetooth-off.svg"),
false,
),
Some(b) if b.enabled && !b.devices.is_empty() => {
(ferese_theme::icons::BLUETOOTH_CONNECTED, true)
}
Some(b) if b.enabled => (ferese_theme::icons::BLUETOOTH_ON, true),
_ => (ferese_theme::icons::BLUETOOTH_OFF, false),
},
Menu::Audio => s.audio.as_ref().map_or((audio_icon(0, true), false), |a| {
(audio_icon(a.volume, a.muted), !a.muted && a.volume > 0)
}),
Menu::Calendar => (include_bytes!("../assets/icons/status/calendar.svg"), true),
Menu::Recording => (include_bytes!("../assets/icons/status/record.svg"), true),
Menu::Calendar => (ferese_theme::icons::CALENDAR, true),
Menu::Recording => (ferese_theme::icons::RECORD, true),
Menu::Battery => {
let Some(b) = &s.battery else {
return (
include_bytes!("../assets/icons/status/battery-empty.svg"),
false,
);
return (ferese_theme::icons::BATTERY_EMPTY, false);
};
let icon: &'static [u8] = if b.status == "Charging" {
include_bytes!("../assets/icons/status/battery-charging.svg")
ferese_theme::icons::BATTERY_CHARGING
} else if b.percent >= 80 {
include_bytes!("../assets/icons/status/battery-full.svg")
ferese_theme::icons::BATTERY_FULL
} else if b.percent >= 50 {
include_bytes!("../assets/icons/status/battery-75.svg")
ferese_theme::icons::BATTERY_75
} else if b.percent >= 20 {
include_bytes!("../assets/icons/status/battery-50.svg")
ferese_theme::icons::BATTERY_50
} else if b.percent > 0 {
include_bytes!("../assets/icons/status/battery-25.svg")
ferese_theme::icons::BATTERY_25
} else {
include_bytes!("../assets/icons/status/battery-empty.svg")
ferese_theme::icons::BATTERY_EMPTY
};
(icon, true)
}
Menu::Notifications => {
if s.notifications.as_ref().is_some_and(|n| n.dnd) {
(
include_bytes!("../assets/icons/status/notifications-off.svg"),
false,
)
(ferese_theme::icons::NOTIFICATIONS_OFF, false)
} else {
(
include_bytes!("../assets/icons/status/notifications.svg"),
true,
)
(ferese_theme::icons::NOTIFICATIONS, true)
}
}
Menu::System => (
include_bytes!("../assets/icons/status/control-center.svg"),
true,
),
Menu::System => (ferese_theme::icons::CONTROL_CENTER, true),
}
}
@@ -1589,21 +1366,6 @@ mod tests {
assert!(caption.ends_with('…'));
}
#[test]
fn calendar_aligns_leap_months_and_year_boundaries() {
let february = jiff::civil::Date::new(2024, 2, 15).unwrap();
let (first, start, days) = calendar_month(february, 0);
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29));
let (first, start, days) = calendar_month(february, -2);
assert_eq!(
(first.year(), first.month(), start, days),
(2023, 12, 4, 31)
);
let june = jiff::civil::Date::new(2025, 6, 1).unwrap();
let (_, start, days) = calendar_month(june, 0);
assert_eq!((start, days), (6, 30));
}
#[test]
fn smaller_popups_have_content_specific_widths() {
assert_eq!(Menu::System.width(), 360.0);
@@ -1663,10 +1425,7 @@ mod tests {
};
assert_eq!(
status_icon(Menu::Audio, &s),
(
include_bytes!("../assets/icons/status/volume-mute.svg").as_slice(),
false
)
(ferese_theme::icons::VOLUME_MUTE, false)
);
s.battery = Some(status::Battery {
@@ -1675,7 +1434,7 @@ mod tests {
});
assert_eq!(
status_icon(Menu::Battery, &s).0,
include_bytes!("../assets/icons/status/battery-charging.svg")
ferese_theme::icons::BATTERY_CHARGING
);
s.notifications = Some(status::Notifications {
@@ -1684,7 +1443,7 @@ mod tests {
});
assert_eq!(
status_icon(Menu::Notifications, &s).0,
include_bytes!("../assets/icons/status/notifications-off.svg")
ferese_theme::icons::NOTIFICATIONS_OFF
);
assert_eq!(
status_label(Menu::Notifications, &s),
@@ -1697,7 +1456,7 @@ mod tests {
});
assert_eq!(
status_icon(Menu::Bluetooth, &s).0,
include_bytes!("../assets/icons/status/bluetooth-off.svg")
ferese_theme::icons::BLUETOOTH_OFF
);
}
@@ -1705,26 +1464,11 @@ mod tests {
fn battery_bands_and_live_label_follow_charge_state() {
let mut s = Snapshot::default();
for (percent, expected) in [
(
0,
include_bytes!("../assets/icons/status/battery-empty.svg").as_slice(),
),
(
19,
include_bytes!("../assets/icons/status/battery-25.svg").as_slice(),
),
(
20,
include_bytes!("../assets/icons/status/battery-50.svg").as_slice(),
),
(
50,
include_bytes!("../assets/icons/status/battery-75.svg").as_slice(),
),
(
80,
include_bytes!("../assets/icons/status/battery-full.svg").as_slice(),
),
(0, ferese_theme::icons::BATTERY_EMPTY),
(19, ferese_theme::icons::BATTERY_25),
(20, ferese_theme::icons::BATTERY_50),
(50, ferese_theme::icons::BATTERY_75),
(80, ferese_theme::icons::BATTERY_FULL),
] {
s.battery = Some(status::Battery {
percent,