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.
No files matched your search
@@ -1,5 +1,6 @@
|
||||
use cosmic::{iced::Color, widget::image};
|
||||
use ferese_config::Document;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Appearance {
|
||||
pub dim: f32,
|
||||
@@ -14,6 +15,7 @@ pub struct Appearance {
|
||||
pub wallpaper: image::Handle,
|
||||
pub avatar: Option<image::Handle>,
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
pub fn load(user: &str, path: Option<&std::path::Path>) -> Self {
|
||||
let doc = path
|
||||
@@ -26,9 +28,7 @@ impl Appearance {
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
};
|
||||
let color = |key, fallback| {
|
||||
parse_color(&string(key, fallback)).unwrap_or_else(|| parse_color(fallback).unwrap())
|
||||
};
|
||||
let palette = ferese_theme::Palette::from_document(doc.as_ref());
|
||||
let font = string("theme.typography.font_family", "Inter");
|
||||
let path = string("theme.background.path", ferese_config::default_wallpaper());
|
||||
let path = if let Some(tail) = path.strip_prefix("~/") {
|
||||
@@ -77,23 +77,16 @@ impl Appearance {
|
||||
show_clock: boolean("lock_screen.show_clock", true),
|
||||
show_date: boolean("lock_screen.show_date", true),
|
||||
twelve_hour: string("lock_screen.clock_format", "24h") == "12h",
|
||||
panel: color("theme.colors.surface_base", "#111821"),
|
||||
text: color("theme.colors.text_primary", "#F4F7FB"),
|
||||
accent: color("theme.colors.accent", "#3D7BE6"),
|
||||
radius: doc
|
||||
.as_ref()
|
||||
.and_then(|d| {
|
||||
d.get("theme.geometry.shell_radius")
|
||||
.or_else(|| d.get("appearance.corner_radius"))
|
||||
})
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(14.)
|
||||
.clamp(0., 64.) as f32,
|
||||
font: cosmic::font::Font::with_name(Box::leak(font.into_boxed_str())),
|
||||
panel: palette.sidebar,
|
||||
text: palette.text,
|
||||
accent: palette.accent,
|
||||
radius: palette.radius,
|
||||
font: ferese_theme::font(Some(&font)),
|
||||
wallpaper,
|
||||
avatar: account_picture(user),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clock_format(&self) -> &'static str {
|
||||
if self.twelve_hour {
|
||||
"%I:%M %p"
|
||||
@@ -101,48 +94,20 @@ impl Appearance {
|
||||
"%H:%M"
|
||||
}
|
||||
}
|
||||
pub fn theme(&self) -> cosmic::Theme {
|
||||
use cosmic::cosmic_theme::{ThemeBuilder, palette::Srgba};
|
||||
let color = |c: Color| Srgba::new(c.r, c.g, c.b, c.a);
|
||||
let builder = if self.panel.r + self.panel.g + self.panel.b > 1.5 {
|
||||
ThemeBuilder::light()
|
||||
} else {
|
||||
ThemeBuilder::dark()
|
||||
};
|
||||
let corners = cosmic::cosmic_theme::CornerRadii {
|
||||
radius_0: Default::default(),
|
||||
radius_xs: [self.radius.min(4.); 4],
|
||||
radius_s: [self.radius.min(8.); 4],
|
||||
radius_m: [self.radius; 4],
|
||||
radius_l: [self.radius; 4],
|
||||
radius_xl: [self.radius; 4],
|
||||
};
|
||||
let mut native = builder
|
||||
.corner_radii(corners)
|
||||
.bg_color(color(self.panel))
|
||||
.primary_container_bg(color(self.panel))
|
||||
.text_tint(color(self.text).color)
|
||||
.accent(ferese_theme::accent_color(self.accent, self.panel))
|
||||
.build();
|
||||
ferese_theme::apply(&mut native, self.text);
|
||||
cosmic::Theme::custom(std::sync::Arc::new(native))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(value: &str) -> Option<Color> {
|
||||
let hex = value.strip_prefix('#')?;
|
||||
let packed = u32::from_str_radix(hex, 16).ok()?;
|
||||
let rgba = match hex.len() {
|
||||
6 => (packed << 8) | 255,
|
||||
8 => packed,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Color::from_rgba8(
|
||||
(rgba >> 24) as u8,
|
||||
(rgba >> 16) as u8,
|
||||
(rgba >> 8) as u8,
|
||||
(rgba & 255) as f32 / 255.,
|
||||
))
|
||||
pub fn theme(&self) -> cosmic::Theme {
|
||||
ferese_theme::Palette {
|
||||
background: self.panel,
|
||||
sidebar: self.panel,
|
||||
card: self.panel,
|
||||
text: self.text,
|
||||
muted: self.text.scale_alpha(0.55),
|
||||
accent: self.accent,
|
||||
radius: self.radius,
|
||||
error: Color::from_rgb8(235, 98, 98),
|
||||
}
|
||||
.native_theme()
|
||||
}
|
||||
}
|
||||
|
||||
fn account_picture(user: &str) -> Option<image::Handle> {
|
||||
|
||||
@@ -343,7 +343,7 @@ impl Locker {
|
||||
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
||||
size: u16,
|
||||
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
||||
widget::text(label).font(self.appearance.font).size(size)
|
||||
ferese_theme::text(label, self.appearance.font).size(size)
|
||||
}
|
||||
|
||||
fn screen(&self) -> Element<'_, Message> {
|
||||
@@ -368,7 +368,9 @@ impl Locker {
|
||||
let mut input = widget::text_input("Password", self.password.as_str())
|
||||
.password()
|
||||
.padding([8, 18])
|
||||
.style(password_style(a.radius, a.accent, a.panel))
|
||||
.style(ferese_theme::controls::lock_input(
|
||||
a.radius, a.accent, a.panel,
|
||||
))
|
||||
.font(a.font)
|
||||
.id(widget::Id::new("password"));
|
||||
|
||||
@@ -530,40 +532,6 @@ fn ferese_symbol() -> widget::icon::Icon {
|
||||
.size(30)
|
||||
}
|
||||
|
||||
fn password_style(radius: f32, accent: iced::Color, surface: iced::Color) -> theme::TextInput {
|
||||
let appearance = move |focused: bool| widget::text_input::Appearance {
|
||||
background: iced::Color::from_rgba(
|
||||
(surface.r + 1.) * 0.5,
|
||||
(surface.g + 1.) * 0.5,
|
||||
(surface.b + 1.) * 0.5,
|
||||
0.24,
|
||||
)
|
||||
.into(),
|
||||
border_radius: (radius * 2.).min(26.).into(),
|
||||
border_width: 1.,
|
||||
border_offset: None,
|
||||
border_color: if focused {
|
||||
accent.scale_alpha(0.7)
|
||||
} else {
|
||||
iced::Color::WHITE.scale_alpha(0.15)
|
||||
},
|
||||
icon_color: Some(iced::Color::WHITE.scale_alpha(0.8)),
|
||||
text_color: Some(iced::Color::WHITE),
|
||||
placeholder_color: iced::Color::WHITE.scale_alpha(0.55),
|
||||
selected_text_color: iced::Color::WHITE,
|
||||
selected_fill: accent.scale_alpha(0.5),
|
||||
label_color: iced::Color::WHITE,
|
||||
};
|
||||
|
||||
theme::TextInput::Custom {
|
||||
active: Box::new(move |_| appearance(false)),
|
||||
hovered: Box::new(move |_| appearance(true)),
|
||||
focused: Box::new(move |_| appearance(true)),
|
||||
error: Box::new(move |_| appearance(false)),
|
||||
disabled: Box::new(move |_| appearance(false)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -29,93 +29,47 @@ impl Appearance {
|
||||
let document = ferese_config::config_path()
|
||||
.and_then(|path| std::fs::read_to_string(path).ok())
|
||||
.and_then(|source| ferese_config::Document::parse(&source).ok());
|
||||
let setting = |key: &str, fallback: &str| {
|
||||
document
|
||||
.as_ref()
|
||||
.and_then(|doc| doc.get(key))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
};
|
||||
let color = |key, fallback| parse_color(&setting(key, fallback), fallback);
|
||||
let family: &'static str =
|
||||
Box::leak(setting("theme.typography.font_family", "Inter").into_boxed_str());
|
||||
let translucent = setting("theme.material.style", "solid") == "translucent";
|
||||
let shell_opacity = document
|
||||
let palette = ferese_theme::Palette::from_document(document.as_ref()).flat();
|
||||
let family = document
|
||||
.as_ref()
|
||||
.and_then(|doc| doc.get("theme.material.opacity"))
|
||||
.and_then(|value| value.as_f64())
|
||||
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
|
||||
.clamp(0., 1.) as f32;
|
||||
|
||||
let mut surface = color("theme.colors.surface_base", "#111821");
|
||||
surface.a = if translucent { shell_opacity } else { 1. };
|
||||
|
||||
.and_then(|document| document.get("theme.typography.font_family"))
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("Inter");
|
||||
Self {
|
||||
surface,
|
||||
text: color("theme.colors.text_primary", "#F4F7FB"),
|
||||
muted: color("theme.colors.text_muted", "#8793A2"),
|
||||
accent: color("theme.colors.accent", "#3D7BE6"),
|
||||
radius: document
|
||||
.as_ref()
|
||||
.and_then(|doc| doc.get("theme.geometry.shell_radius"))
|
||||
.and_then(|value| value.as_f64())
|
||||
.unwrap_or(14.)
|
||||
.clamp(8., 36.) as f32,
|
||||
font: cosmic::font::Font {
|
||||
family: cosmic::iced::font::Family::Name(family),
|
||||
..cosmic::font::default()
|
||||
surface: Color {
|
||||
a: ferese_theme::material_opacity(document.as_ref()),
|
||||
..palette.sidebar
|
||||
},
|
||||
text: palette.text,
|
||||
muted: palette.muted,
|
||||
accent: palette.accent,
|
||||
radius: palette.radius,
|
||||
font: ferese_theme::font(Some(family)),
|
||||
}
|
||||
}
|
||||
|
||||
fn palette(&self) -> ferese_theme::Palette {
|
||||
ferese_theme::Palette {
|
||||
background: self.surface,
|
||||
sidebar: self.surface,
|
||||
card: self.surface,
|
||||
text: self.text,
|
||||
muted: self.muted,
|
||||
accent: self.accent,
|
||||
radius: self.radius,
|
||||
error: Color::from_rgb8(235, 98, 98),
|
||||
}
|
||||
}
|
||||
|
||||
fn theme(&self) -> cosmic::Theme {
|
||||
use cosmic::cosmic_theme::{ThemeBuilder, palette::Srgba};
|
||||
|
||||
let rgba = |color: Color| Srgba::new(color.r, color.g, color.b, color.a);
|
||||
let opaque_surface = Color {
|
||||
a: 1.,
|
||||
..self.surface
|
||||
};
|
||||
let builder = if self.surface.r + self.surface.g + self.surface.b > 1.5 {
|
||||
ThemeBuilder::light()
|
||||
} else {
|
||||
ThemeBuilder::dark()
|
||||
};
|
||||
let corners = cosmic::cosmic_theme::CornerRadii {
|
||||
radius_xs: [self.radius.min(4.); 4],
|
||||
radius_s: [self.radius.min(8.); 4],
|
||||
radius_m: [self.radius; 4],
|
||||
radius_l: [self.radius; 4],
|
||||
radius_xl: [self.radius; 4],
|
||||
radius_0: Default::default(),
|
||||
};
|
||||
|
||||
let mut native = builder
|
||||
.corner_radii(corners)
|
||||
.bg_color(rgba(opaque_surface))
|
||||
.primary_container_bg(rgba(opaque_surface))
|
||||
.text_tint(rgba(self.text).color)
|
||||
.accent(ferese_theme::accent_color(self.accent, opaque_surface))
|
||||
.build();
|
||||
ferese_theme::apply(&mut native, self.text);
|
||||
cosmic::Theme::custom(Arc::new(native))
|
||||
self.palette().native_theme()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(input: &str, fallback: &str) -> Color {
|
||||
let parse = |source: &str| {
|
||||
let value = u32::from_str_radix(source.strip_prefix('#')?, 16).ok()?;
|
||||
(source.len() == 7)
|
||||
.then(|| Color::from_rgb8((value >> 16) as u8, (value >> 8) as u8, value as u8))
|
||||
};
|
||||
parse(input)
|
||||
.or_else(|| parse(fallback))
|
||||
.unwrap_or(Color::BLACK)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
WindowOpened(cosmic::iced::window::Id),
|
||||
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
|
||||
Tick,
|
||||
Input(String),
|
||||
Submit,
|
||||
@@ -130,6 +84,7 @@ impl std::fmt::Debug for Message {
|
||||
|
||||
struct Prompt {
|
||||
core: Core,
|
||||
material: Option<ferese_theme::material::ModalMaterial>,
|
||||
appearance: Appearance,
|
||||
incoming: Arc<Mutex<VecDeque<PromptEvent>>>,
|
||||
description: String,
|
||||
@@ -154,37 +109,6 @@ fn content_height(description: &str, status: Option<&str>) -> f32 {
|
||||
(214 + 18 * lines.saturating_sub(1) + status_height).min(360) as f32
|
||||
}
|
||||
|
||||
fn input_style(palette: &Appearance) -> theme::TextInput {
|
||||
let text = palette.text;
|
||||
let muted = palette.muted;
|
||||
let accent = palette.accent;
|
||||
let radius = palette.radius.min(10.);
|
||||
let appearance = move |focused: bool| widget::text_input::Appearance {
|
||||
background: Color::from_rgba(text.r, text.g, text.b, 0.045).into(),
|
||||
border_radius: radius.into(),
|
||||
border_width: 1.,
|
||||
border_offset: None,
|
||||
border_color: if focused {
|
||||
accent.scale_alpha(0.82)
|
||||
} else {
|
||||
muted.scale_alpha(0.26)
|
||||
},
|
||||
icon_color: Some(muted),
|
||||
text_color: Some(text),
|
||||
placeholder_color: muted,
|
||||
selected_text_color: text,
|
||||
selected_fill: accent.scale_alpha(0.35),
|
||||
label_color: text,
|
||||
};
|
||||
theme::TextInput::Custom {
|
||||
active: Box::new(move |_| appearance(false)),
|
||||
hovered: Box::new(move |_| appearance(true)),
|
||||
focused: Box::new(move |_| appearance(true)),
|
||||
error: Box::new(move |_| appearance(false)),
|
||||
disabled: Box::new(move |_| appearance(false)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut lines = BufReader::new(stdin).lines();
|
||||
@@ -275,7 +199,11 @@ impl cosmic::Application for Prompt {
|
||||
|
||||
fn style(&self) -> Option<cosmic::iced::theme::Style> {
|
||||
Some(cosmic::iced::theme::Style {
|
||||
background_color: self.appearance.surface,
|
||||
background_color: if self.material.is_some() {
|
||||
cosmic::iced::Color::TRANSPARENT
|
||||
} else {
|
||||
self.appearance.surface
|
||||
},
|
||||
text_color: self.appearance.text,
|
||||
icon_color: self.appearance.text,
|
||||
})
|
||||
@@ -296,6 +224,7 @@ impl cosmic::Application for Prompt {
|
||||
(
|
||||
Self {
|
||||
core,
|
||||
material: None,
|
||||
appearance,
|
||||
incoming,
|
||||
description,
|
||||
@@ -313,11 +242,29 @@ impl cosmic::Application for Prompt {
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick)
|
||||
Subscription::batch([
|
||||
cosmic::iced::time::every(Duration::from_millis(30)).map(|_| Message::Tick),
|
||||
cosmic::iced::event::listen_with(|event, _, id| match event {
|
||||
cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => {
|
||||
Some(Message::WindowOpened(id))
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::WindowOpened(id) => {
|
||||
return cosmic::iced::window::run(
|
||||
id,
|
||||
ferese_theme::material::ModalMaterial::attach,
|
||||
)
|
||||
.map(|result| cosmic::Action::App(Message::MaterialAttached(result)));
|
||||
}
|
||||
Message::MaterialAttached(result) => {
|
||||
self.material = result.ok();
|
||||
}
|
||||
Message::Tick => {
|
||||
let events: Vec<_> = self.incoming.lock().unwrap().drain(..).collect();
|
||||
let mut refocus = false;
|
||||
@@ -375,7 +322,9 @@ impl cosmic::Application for Prompt {
|
||||
.id(widget::Id::new("auth-response"))
|
||||
.font(palette.font)
|
||||
.padding([7, 12])
|
||||
.style(input_style(palette));
|
||||
.style(ferese_theme::controls::authentication_input(
|
||||
palette.palette(),
|
||||
));
|
||||
if !self.echo {
|
||||
input = input.password();
|
||||
}
|
||||
@@ -390,23 +339,7 @@ impl cosmic::Application for Prompt {
|
||||
.height(Length::Fixed(36.))
|
||||
.padding([8, 16])
|
||||
.on_press_maybe((!self.waiting && !self.answer.is_empty()).then_some(Message::Submit));
|
||||
let lock_svg = std::str::from_utf8(include_bytes!(
|
||||
"../../ferese-shell/assets/icons/status/lock.svg"
|
||||
))
|
||||
.expect("embedded lock icon is valid UTF-8")
|
||||
.replace(
|
||||
"currentColor",
|
||||
&format!(
|
||||
"#{:02x}{:02x}{:02x}",
|
||||
(palette.accent.r * 255.) as u8,
|
||||
(palette.accent.g * 255.) as u8,
|
||||
(palette.accent.b * 255.) as u8,
|
||||
),
|
||||
);
|
||||
let lock_icon = widget::icon::from_svg_bytes(lock_svg.into_bytes())
|
||||
.symbolic(false)
|
||||
.icon()
|
||||
.size(20);
|
||||
let lock_icon = ferese_theme::icons::tinted(ferese_theme::icons::LOCK, 20, palette.accent);
|
||||
let heading = row![
|
||||
container(lock_icon)
|
||||
.width(40)
|
||||
|
||||
@@ -128,7 +128,7 @@ impl cosmic::Application for App {
|
||||
let error = initial.as_ref().err().cloned();
|
||||
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
|
||||
let font = visuals::configured_font(¤t);
|
||||
let native_palette = visuals::Palette::from(¤t);
|
||||
let native_palette = visuals::Palette::from_document(Some(¤t.doc));
|
||||
let mut app = Self {
|
||||
note_editors: HashMap::new(),
|
||||
core,
|
||||
@@ -517,7 +517,7 @@ impl cosmic::Application for App {
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
let palette = visuals::Palette::from(&self.draft);
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let mut sidebar = column([])
|
||||
.spacing(3)
|
||||
.push(
|
||||
@@ -1013,7 +1013,7 @@ impl cosmic::Application for App {
|
||||
|
||||
impl App {
|
||||
fn update_theme(&mut self) -> Task<Message> {
|
||||
let palette = visuals::Palette::from(&self.draft);
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
if palette == self.native_palette {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -1109,18 +1109,18 @@ impl App {
|
||||
text: impl Into<std::borrow::Cow<'a, str>> + 'a,
|
||||
size: f32,
|
||||
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
|
||||
widget::text(text).size(size).font(self.font)
|
||||
ferese_theme::text(text, self.font).size(size)
|
||||
}
|
||||
|
||||
fn note(&self, text: &str) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from(&self.draft);
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
self.label(text.to_owned(), 12.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn field(&self, field: Field) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from(&self.draft);
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let mut labels = column([])
|
||||
.spacing(3)
|
||||
.push(self.label(field.label.clone(), 13.));
|
||||
|
||||
@@ -5,83 +5,17 @@ use crate::{
|
||||
};
|
||||
use cosmic::{
|
||||
Element,
|
||||
iced::{Background, Border, Color, Length, Vector},
|
||||
theme,
|
||||
widget::{button, container, icon as svg_icon},
|
||||
iced::{Color, Length},
|
||||
widget::icon as svg_icon,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub struct Palette {
|
||||
pub background: Color,
|
||||
pub sidebar: Color,
|
||||
pub card: Color,
|
||||
pub accent: Color,
|
||||
pub text: Color,
|
||||
pub muted: Color,
|
||||
pub error: Color,
|
||||
}
|
||||
pub use ferese_theme::controls::{
|
||||
button_style, navigation_style, settings_input as input_style, surface,
|
||||
};
|
||||
pub use ferese_theme::{Palette, mix, surface_shade};
|
||||
|
||||
pub fn color(value: &str, fallback: Color) -> Color {
|
||||
let Some(value) = value.strip_prefix('#') else {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
if value.len() != 6 {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
u32::from_str_radix(value, 16)
|
||||
.map(|rgb| Color::from_rgb8((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8))
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
pub fn from(snapshot: &Snapshot) -> Self {
|
||||
let base = color(
|
||||
&snapshot.string("theme.colors.surface_base", "#111821"),
|
||||
Color::from_rgb8(15, 16, 20),
|
||||
);
|
||||
Self {
|
||||
background: mix(base, surface_shade(base), 0.025),
|
||||
sidebar: base,
|
||||
card: mix(base, surface_shade(base), 0.055),
|
||||
accent: color(
|
||||
&snapshot.string("theme.colors.accent", "#3D7BE6"),
|
||||
Color::from_rgb8(61, 123, 230),
|
||||
),
|
||||
text: color(
|
||||
&snapshot.string("theme.colors.text_primary", "#F4F7FB"),
|
||||
Color::WHITE,
|
||||
),
|
||||
muted: color(
|
||||
&snapshot.string("theme.colors.text_muted", "#8793A2"),
|
||||
Color::from_rgb8(150, 149, 159),
|
||||
),
|
||||
error: mix(base, Color::from_rgb8(210, 80, 80), 0.2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use ferese_theme::luminance;
|
||||
|
||||
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,
|
||||
a.g + (b.g - a.g) * t,
|
||||
a.b + (b.b - a.b) * t,
|
||||
)
|
||||
ferese_theme::parse_color(value).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn hex(c: Color) -> String {
|
||||
@@ -93,245 +27,32 @@ fn hex(c: Color) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
|
||||
theme::Container::custom(move |_| container::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
border: Border {
|
||||
radius: radius.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn button_style(p: Palette, selected: bool) -> theme::Button {
|
||||
styled_button(p, selected, false)
|
||||
}
|
||||
|
||||
pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
|
||||
styled_button(p, selected, true)
|
||||
}
|
||||
|
||||
pub fn input_style(p: Palette) -> theme::TextInput {
|
||||
let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance {
|
||||
background: mix(p.sidebar, p.card, if hovered { 0.65 } else { 0.4 }).into(),
|
||||
border_radius: 7.into(),
|
||||
border_width: if focused { 1. } else { 0. },
|
||||
border_offset: None,
|
||||
border_color: p.accent,
|
||||
icon_color: Some(p.muted),
|
||||
text_color: Some(p.text),
|
||||
placeholder_color: p.muted,
|
||||
selected_text_color: p.sidebar,
|
||||
selected_fill: p.accent,
|
||||
label_color: p.muted,
|
||||
};
|
||||
theme::TextInput::Custom {
|
||||
active: Box::new(move |_| appearance(false, false)),
|
||||
hovered: Box::new(move |_| appearance(false, true)),
|
||||
focused: Box::new(move |_| appearance(true, true)),
|
||||
error: Box::new(move |_| appearance(true, false)),
|
||||
disabled: Box::new(move |_| appearance(false, false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button {
|
||||
let style = move |hover: bool| {
|
||||
let background = if selected {
|
||||
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
|
||||
} else if hover {
|
||||
mix(p.card, surface_shade(p.sidebar), 0.06)
|
||||
} else if navigation {
|
||||
p.sidebar
|
||||
} else {
|
||||
p.card
|
||||
};
|
||||
let (background, on) = if selected {
|
||||
ferese_theme::accent_pair(background, p.text)
|
||||
} else {
|
||||
(background, p.text)
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
text_color: Some(on),
|
||||
icon_color: Some(on),
|
||||
border_radius: 9.into(),
|
||||
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,
|
||||
shadow_offset: Vector::ZERO,
|
||||
}
|
||||
};
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
let Some(snapshot) = snapshot else {
|
||||
let mut native = cosmic::theme::COSMIC_DARK.clone();
|
||||
let preferred = native.background(false).on.into();
|
||||
ferese_theme::apply(&mut native, preferred);
|
||||
return cosmic::Theme::custom(std::sync::Arc::new(native));
|
||||
};
|
||||
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 mut theme = builder
|
||||
.bg_color(cosmic_color(palette.background))
|
||||
.primary_container_bg(cosmic_color(palette.card))
|
||||
.text_tint(cosmic_color(palette.text).color)
|
||||
.accent(ferese_theme::accent_color(palette.accent, palette.card))
|
||||
.build();
|
||||
|
||||
ferese_theme::apply(&mut theme, palette.text);
|
||||
cosmic::Theme::custom(std::sync::Arc::new(theme))
|
||||
Palette::from_document(snapshot.map(|snapshot| &snapshot.doc)).native_theme()
|
||||
}
|
||||
|
||||
pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
|
||||
static FONTS: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
let family = snapshot.string("theme.typography.font_family", "Inter");
|
||||
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
|
||||
if let Some(font) = fonts.get(&family) {
|
||||
return *font;
|
||||
}
|
||||
|
||||
if fonts.len() >= 32 {
|
||||
return cosmic::font::default();
|
||||
}
|
||||
|
||||
let font = cosmic::font::Font::with_name(Box::leak(family.clone().into_boxed_str()));
|
||||
fonts.insert(family, font);
|
||||
|
||||
font
|
||||
ferese_theme::font(Some(
|
||||
&snapshot.string("theme.typography.font_family", "Inter"),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="{}" fill="none" stroke="{}" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>"##,
|
||||
page.icon(),
|
||||
hex(tint),
|
||||
);
|
||||
svg_icon::from_svg_bytes(svg.into_bytes())
|
||||
.symbolic(false)
|
||||
.icon()
|
||||
.size(19)
|
||||
ferese_theme::icons::outline(page.icon(), tint, 19)
|
||||
}
|
||||
|
||||
pub fn brand_icon() -> svg_icon::Icon {
|
||||
svg_icon::from_svg_bytes(include_bytes!("../../../packaging/icons/ferese.svg").as_slice())
|
||||
cosmic::widget::icon::from_svg_bytes(ferese_theme::icons::APPLICATION)
|
||||
.symbolic(false)
|
||||
.icon()
|
||||
.size(32)
|
||||
}
|
||||
|
||||
pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="{path}" fill="none" stroke="{}" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>"##,
|
||||
hex(tint),
|
||||
);
|
||||
svg_icon::from_svg_bytes(svg.into_bytes())
|
||||
.symbolic(false)
|
||||
.icon()
|
||||
.size(16)
|
||||
ferese_theme::icons::outline(path, tint, 16)
|
||||
}
|
||||
|
||||
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 use ferese_theme::{PRESETS, Preset};
|
||||
|
||||
pub fn preset(index: usize) -> Vec<Edit> {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
@@ -437,7 +158,7 @@ pub fn preset_preview(index: usize) -> Element<'static, Message> {
|
||||
}
|
||||
|
||||
pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
|
||||
let p = Palette::from(snapshot);
|
||||
let p = Palette::from_document(Some(&snapshot.doc));
|
||||
let base = hex(p.sidebar);
|
||||
let card = hex(p.card);
|
||||
let accent = hex(p.accent);
|
||||
@@ -505,16 +226,14 @@ mod tests {
|
||||
}
|
||||
|
||||
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)
|
||||
ferese_theme::contrast(a, b)
|
||||
}
|
||||
|
||||
#[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);
|
||||
let palette = Palette::from_document(Some(&snapshot.doc));
|
||||
for background in [palette.background, palette.sidebar, palette.card] {
|
||||
assert!(
|
||||
contrast(palette.text, background) >= 4.5,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
|
||||
<path d="M22 10v4"/>
|
||||
<rect x="5" y="9" width="12" height="6" rx="0.9" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 355 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" role="img" aria-label="Ferese symbol: tiled F">
|
||||
<rect x="20" y="20" width="50" height="160" rx="8" fill="currentColor"/>
|
||||
<rect x="80" y="20" width="100" height="55" rx="8" fill="currentColor"/>
|
||||
<rect x="80" y="95" width="70" height="45" rx="8" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 341 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.8"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.8"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.8"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 403 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3c-.9 0-1.5.7-1.5 1.7V9L3 13v2l7.5-2v4.5L8 19v1.5l4-1 4 1V19l-2.5-1.5V13l7.5 2v-2l-7.5-4V4.7C13.5 3.7 12.9 3 12 3Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 324 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery 25">
|
||||
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
|
||||
<rect x="5" y="9" width="4.75" height="6" rx="1.2" fill="#e0654f"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 344 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery 50">
|
||||
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
|
||||
<rect x="5" y="9" width="9.5" height="6" rx="1.2" fill="#3d7be6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 343 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery 75">
|
||||
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
|
||||
<rect x="5" y="9" width="14.25" height="6" rx="1.2" fill="#3d7be6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 345 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery charging">
|
||||
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
|
||||
<rect x="5" y="9" width="19" height="6" rx="1.2" fill="#3d7be6" opacity="0.35"/>
|
||||
<path d="M16,7 L12,13 H15 L14,17 L19,11 H16 Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 432 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery empty">
|
||||
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 279 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 24" role="img" aria-label="Battery full">
|
||||
<rect x="2" y="6" width="25" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="28" y="10" width="2" height="4" rx="1" fill="currentColor"/>
|
||||
<rect x="5" y="9" width="19" height="6" rx="1.2" fill="#63c168"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 344 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Bluetooth">
|
||||
<g transform="translate(-11 -4)"><path d="M30,10 L30,58 M30,10 L42,22 L30,34 L42,46 L30,58" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></g>
|
||||
<g transform="translate(-11 -4)"><circle cx="49" cy="34" r="4" fill="#3d7be6"/></g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 382 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Bluetooth off">
|
||||
<g transform="translate(-6 -4)"><path d="M30,10 L30,58 M30,10 L42,22 L30,34 L42,46 L30,58" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/></g>
|
||||
<path d="M8,8 L52,52" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 413 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Bluetooth">
|
||||
<g transform="translate(-6 -4)"><path d="M30,10 L30,58 M30,10 L42,22 L30,34 L42,46 L30,58" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.4 1.4m11.2 11.2L19 19M19 5l-1.4 1.4M6.4 17.6 5 19"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 324 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 10h18"/><path d="M8 14h2m4 0h2m-8 4h2m4 0h2"/></svg>
|
||||
|
Before Width: | Height: | Size: 287 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m5 9 7 7 7-7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 218 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m5 15 7-7 7 7"/></svg>
|
||||
|
Before Width: | Height: | Size: 192 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18"/></svg>
|
||||
|
Before Width: | Height: | Size: 175 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Control center">
|
||||
<g transform="translate(0 -4)">
|
||||
<rect x="10" y="14" width="40" height="16" rx="8" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
<circle cx="38" cy="22" r="6" fill="#3d7be6"/>
|
||||
<rect x="10" y="38" width="40" height="16" rx="8" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
<circle cx="22" cy="46" r="6" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 449 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 14.2A8.4 8.4 0 1 1 9.8 4a6.6 6.6 0 0 0 10.2 10.2Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 259 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 3h14v12h-4v3H9v-3H5ZM8 3v4m4-4v4m4-4v4M12 18v3M8 21h8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 262 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="10" width="14" height="11" rx="2.8"/>
|
||||
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14.5v2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 294 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 6a3 3 0 0 1 6 0v5M5.5 11.5a6.5 6.5 0 0 0 10.6 5M18.5 11.5a6.5 6.5 0 0 1-.4 2.3M12 18v3m-3 0h6"/>
|
||||
<path d="M4 4l16 16"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 327 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="3" width="6" height="11" rx="3"/>
|
||||
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3m-3 0h6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 300 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 14.2A8.4 8.4 0 1 1 9.8 4a6.6 6.6 0 0 0 10.2 10.2Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 259 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Notification off">
|
||||
<g transform="translate(0 -2)">
|
||||
<path d="M30,10 A16,16 0 0,1 46,26 L46,38 L14,38 L14,26 A16,16 0 0,1 30,10 Z" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="10" y="38" width="40" height="5" rx="2" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="30" cy="50" r="4" fill="currentColor" opacity="0.5"/>
|
||||
<path d="M8,8 L52,56" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 509 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Notification">
|
||||
<g transform="translate(0 -2)">
|
||||
<path d="M30,10 A16,16 0 0,1 46,26 L46,38 L14,38 L14,26 A16,16 0 0,1 30,10 Z" fill="currentColor"/>
|
||||
<rect x="10" y="38" width="40" height="5" rx="2" fill="currentColor"/>
|
||||
<circle cx="30" cy="50" r="4" fill="#3d7be6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 360 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M7 12h10"/></svg>
|
||||
|
Before Width: | Height: | Size: 226 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v9"/><path d="M7 5.5a9 9 0 1 0 10 0"/></svg>
|
||||
|
Before Width: | Height: | Size: 219 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 1 0 9 9"/><path d="M12 12 18 6"/><circle cx="12" cy="12" r="1"/></svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 4c-8 0-14 3-14 10a6 6 0 0 0 6 6c7 0 10-6 8-16Z"/><path d="M4 21c2-5 6-8 12-11"/></svg>
|
||||
|
Before Width: | Height: | Size: 260 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2.8v8M7.4 5.9a8 8 0 1 0 9.2 0"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 239 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Cancel display selection"><circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/><path d="m23 23 14 14m0-14L23 37" fill="none" stroke="#3d7be6" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
Before Width: | Height: | Size: 303 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Saving recording"><circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/><g fill="#3d7be6"><circle cx="20" cy="30" r="3"/><circle cx="30" cy="30" r="3"/><circle cx="40" cy="30" r="3"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 306 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Stop recording">
|
||||
<circle cx="30" cy="30" r="22" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
<rect x="20" y="20" width="20" height="20" rx="4" fill="#3d7be6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 258 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Record screen">
|
||||
<rect x="8" y="10" width="44" height="32" rx="7" fill="none" stroke="currentColor" stroke-width="3"/>
|
||||
<path d="M23 50h14M30 42v8" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="30" cy="26" r="9" fill="#3d7be6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 359 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8 8 0 1 1-2.3-5.7"/><path d="M20 4v5h-5"/></svg>
|
||||
|
Before Width: | Height: | Size: 225 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Settings">
|
||||
<g fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M25 7h10l2 7 5 3 7-2 5 9-5 5v6l5 5-5 9-7-2-5 3-2 7H25l-2-7-5-3-7 2-5-9 5-5v-6l-5-5 5-9 7 2 5-3z" transform="translate(0 -2)"/>
|
||||
<circle cx="30" cy="30" r="8" stroke="#3d7be6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 393 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 230 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v5M14 11v5"/></svg>
|
||||
|
Before Width: | Height: | Size: 229 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Audio">
|
||||
<g transform="translate(3 0)">
|
||||
<path d="M27.5,12.5 L15,22.5 L5,22.5 L5,37.5 L15,37.5 L27.5,47.5 Z" fill="currentColor"/>
|
||||
<path d="M34,22 Q40,30 34,38" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M40,15 Q50,30 40,45" fill="none" stroke="#3d7be6" stroke-width="4" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 431 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Audio">
|
||||
<g transform="translate(3 0)">
|
||||
<path d="M27.5,12.5 L15,22.5 L5,22.5 L5,37.5 L15,37.5 L27.5,47.5 Z" fill="currentColor"/>
|
||||
<path d="M34,22 Q40,30 34,38" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M40,15 Q50,30 40,45" fill="none" opacity="0.18" stroke="#3d7be6" stroke-width="4" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 446 B |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Audio">
|
||||
<g transform="translate(3 0)">
|
||||
<path d="M27.5,12.5 L15,22.5 L5,22.5 L5,37.5 L15,37.5 L27.5,47.5 Z" fill="currentColor"/>
|
||||
<path d="M34,22 Q40,30 34,38" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M40,15 Q50,30 40,45" fill="none" opacity="0.35" stroke="#3d7be6" stroke-width="4" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 446 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Audio off">
|
||||
<g transform="translate(3 0)">
|
||||
<path d="M27.5,12.5 L15,22.5 L5,22.5 L5,37.5 L15,37.5 L27.5,47.5 Z" fill="currentColor" opacity="0.5"/>
|
||||
<path d="M6,6 L54,54" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 340 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Wifi">
|
||||
<g transform="translate(3 -1.5) scale(0.9)">
|
||||
<circle cx="30" cy="53" r="3" fill="#3d7be6"/>
|
||||
<path d="M21,44 Q30,34 39,44" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M12,35 Q30,15 48,35" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M3,26 Q30,5 57,26" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 510 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Wifi">
|
||||
<g transform="translate(3 -1.5) scale(0.9)">
|
||||
<circle cx="30" cy="53" r="3" fill="#3d7be6"/>
|
||||
<path d="M21,44 Q30,34 39,44" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
<path opacity="0.25" d="M12,35 Q30,15 48,35" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
<path opacity="0.25" d="M3,26 Q30,5 57,26" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 540 B |
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Wifi">
|
||||
<g transform="translate(3 -1.5) scale(0.9)">
|
||||
<circle cx="30" cy="53" r="3" fill="#3d7be6"/>
|
||||
<path d="M21,44 Q30,34 39,44" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M12,35 Q30,15 48,35" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
<path opacity="0.25" d="M3,26 Q30,5 57,26" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 525 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" role="img" aria-label="Wifi off">
|
||||
<g transform="translate(3 -1.5) scale(0.9)">
|
||||
<circle cx="30" cy="53" r="3" fill="currentColor" opacity="0.5"/>
|
||||
<path d="M21,44 Q30,34 39,44" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M12,35 Q30,15 48,35" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M3,26 Q30,5 57,26" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M6,6 L54,54" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 673 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9h4l5-4v14l-5-4H3Z"/>
|
||||
<path d="M15.2 8.5a5 5 0 0 1 0 7"/>
|
||||
<path d="M18.4 5.5a9.2 9.2 0 0 1 0 13"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 308 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.5 7.8a14.5 14.5 0 0 1 19 0"/>
|
||||
<path d="M5.7 11.4a9.6 9.6 0 0 1 12.6 0"/>
|
||||
<path d="M8.9 15a4.7 4.7 0 0 1 6.2 0"/>
|
||||
<circle cx="12" cy="18.4" r="1.2" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 394 B |
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||