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

Generated
+4
View File
@@ -1788,7 +1788,11 @@ dependencies = [
name = "ferese-theme"
version = "0.1.0"
dependencies = [
"ferese-config",
"ferese-protocols",
"jiff",
"libcosmic",
"wayland-client",
]
[[package]]
+1
View File
@@ -24,6 +24,7 @@ cohesive experience—beautiful by default and unmistakably yours.
workspace overview.
- **A native shell:** menu bar, control center, settings, notifications and
desktop widgets designed to belong together, including themed authentication prompts.
Native apps share the [Ferese theme components](crates/ferese-theme/README.md).
- **Personal by design:** themes, wallpapers, accent colors, blur, borders and
motion that update without restarting your session.
- **Ready for real setups:** gestures, multi-monitor support, fractional
+85
View File
@@ -7,6 +7,10 @@ use ferese_protocols::effects::v1::server::{
ferese_effects_manager_v1::{self, FereseEffectsManagerV1},
ferese_surface_effects_v1::{self, FereseSurfaceEffectsV1},
};
use ferese_protocols::material::v1::server::{
ferese_material_manager_v1::{self, FereseMaterialManagerV1},
ferese_surface_material_v1::{self, FereseSurfaceMaterialV1},
};
use smithay::{
reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, WEnum, Weak,
@@ -112,6 +116,87 @@ impl SurfaceEffectsUserData {
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseEffectsManagerV1, _>(3, ());
display.create_global::<Ferese, FereseMaterialManagerV1, _>(1, ());
}
impl GlobalDispatch<FereseMaterialManagerV1, ()> for Ferese {
fn bind(
_state: &mut Self,
_display: &DisplayHandle,
_client: &Client,
resource: New<FereseMaterialManagerV1>,
_global_data: &(),
data_init: &mut DataInit<'_, Self>,
) {
data_init.init(resource, ());
}
}
impl Dispatch<FereseMaterialManagerV1, ()> for Ferese {
fn request(
state: &mut Self,
_client: &Client,
manager: &FereseMaterialManagerV1,
request: ferese_material_manager_v1::Request,
_data: &(),
_display: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
match request {
ferese_material_manager_v1::Request::GetSurfaceMaterial { id, surface } => {
let attached = with_states(&surface, |states| {
states
.data_map
.insert_if_missing_threadsafe(SurfaceEffectsState::new);
let effects = states.data_map.get::<SurfaceEffectsState>().unwrap();
effects.attached.swap(true, Ordering::AcqRel)
});
if attached {
manager.post_error(
ferese_material_manager_v1::Error::AlreadyConstructed,
"surface already has material or effects",
);
return;
}
let material = data_init.init(id, SurfaceEffectsUserData::new(surface.clone()));
set_surface_role(&surface, Some(SemanticRole::Modal));
material.ready();
crate::backends::direct::render_all(state);
}
ferese_material_manager_v1::Request::Destroy => {}
_ => unreachable!(),
}
}
}
impl Dispatch<FereseSurfaceMaterialV1, SurfaceEffectsUserData> for Ferese {
fn request(
state: &mut Self,
_client: &Client,
_material: &FereseSurfaceMaterialV1,
request: ferese_surface_material_v1::Request,
data: &SurfaceEffectsUserData,
_display: &DisplayHandle,
_data_init: &mut DataInit<'_, Self>,
) {
if matches!(request, ferese_surface_material_v1::Request::Destroy)
&& let Some(surface) = data.surface()
&& detach(&surface)
{
crate::backends::direct::render_all(state);
}
}
fn destroyed(
_state: &mut Self,
_client: ClientId,
_material: &FereseSurfaceMaterialV1,
data: &SurfaceEffectsUserData,
) {
if let Some(surface) = data.surface() {
detach(&surface);
}
}
}
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[i32; 5]>> {
+93 -33
View File
@@ -370,6 +370,7 @@ uniform float alpha;
uniform vec4 visible_rect;
uniform float material_radius;
uniform vec2 texture_size;
uniform vec2 capture_origin;
uniform float blur_radius;
uniform float presentation_alpha;
uniform float background_opacity;
@@ -381,23 +382,25 @@ void main() {
float sdf = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - material_radius;
float coverage = alpha * presentation_alpha * background_opacity * (1.0 - smoothstep(-0.5, 0.5, sdf));
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
// Denser Gaussian sampling preserves the same radius and softness without
// the visible grid left by widely spaced taps on detailed wallpapers.
vec2 step_size = vec2(blur_radius / 6.0) / max(texture_size, vec2(1.0));
vec2 coords = (gl_FragCoord.xy - capture_origin) / texture_size;
vec4 color = vec4(0.0);
float weights = 0.0;
for (int y=-6; y<=6; y++) {
for (int x=-6; x<=6; x++) {
float weight = exp(-float(x*x+y*y) / 18.0);
color += texture2D(tex, v_coords + vec2(float(x),float(y))*step_size) * weight;
weights += weight;
}
// A spiral avoids aligning the sampling lattice with wallpaper patterns.
for (int i = 0; i < 256; i++) {
float radius = sqrt((float(i) + 0.5) / 256.0);
float angle = float(i) * 2.39996323;
vec2 offset = vec2(cos(angle), sin(angle)) * radius * blur_radius / texture_size;
float weight = exp(-4.5 * radius * radius);
color += texture2D(tex, coords + offset) * weight;
weights += weight;
}
vec3 background = color.rgb / weights;
gl_FragColor = vec4(mix(background, tint.rgb, tint.a) * coverage, coverage);
}
"#;
const BLURRED_MATERIAL_TINT: f32 = 0.5;
#[derive(Clone, Debug)]
pub(crate) struct BlurProgram(GlesTexProgram);
@@ -516,7 +519,23 @@ impl RenderElement<GlesRenderer> for BlurRenderElement {
// clean captured scene. Recapturing a partly repainted framebuffer
// would feed previous blurred pixels back into the blur.
if self.capture_dirty.load(Ordering::Relaxed) {
frame.with_context(|gl| unsafe {
let error = frame.with_context(|gl| unsafe {
gl.GetError();
let mut framebuffer = 0;
let mut previous_read_buffer = 0;
gl.GetIntegerv(
smithay::backend::renderer::gles::ffi::READ_FRAMEBUFFER_BINDING,
&mut framebuffer,
);
gl.GetIntegerv(
smithay::backend::renderer::gles::ffi::READ_BUFFER,
&mut previous_read_buffer,
);
gl.ReadBuffer(if framebuffer == 0 {
smithay::backend::renderer::gles::ffi::BACK
} else {
smithay::backend::renderer::gles::ffi::COLOR_ATTACHMENT0
});
gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, texture);
gl.TexParameteri(
smithay::backend::renderer::gles::ffi::TEXTURE_2D,
@@ -539,7 +558,13 @@ impl RenderElement<GlesRenderer> for BlurRenderElement {
height,
);
gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, 0);
let error = gl.GetError();
gl.ReadBuffer(previous_read_buffer as u32);
error
})?;
if error != smithay::backend::renderer::gles::ffi::NO_ERROR {
tracing::warn!(error, "backdrop framebuffer capture failed");
}
self.capture_dirty.store(false, Ordering::Relaxed);
}
@@ -1144,6 +1169,18 @@ fn output_elements(
for (window, id, visual, decoration_progress, close_alpha) in windows {
let constrain = rounded_visual_rect(visual, output_geometry.loc);
let pixels = physical_rect(visual, output_geometry.loc, scale);
let material_surface = window
.toplevel()
.map(|toplevel| toplevel.wl_surface())
.filter(|surface| {
crate::effects::surface_role(surface)
.is_some_and(|(role, _)| role == crate::effects::SemanticRole::Modal)
});
let window_radius = if material_surface.is_some() {
state.theme_settings.material_radius
} else {
state.theme_settings.window_radius
} * decoration_progress;
// Only overview/close intentionally scale the complete application.
let scale_content = state.overview.is_presenting() || state.closing_visual(id).0 != 1.0;
let behavior = resize_content_behavior(scale_content);
@@ -1156,7 +1193,7 @@ fn output_elements(
constrain,
pixels,
scale,
state.theme_settings.window_radius * decoration_progress,
window_radius,
[0.0, 0.0, 0.0, dim as f32 * close_alpha],
false,
output,
@@ -1167,7 +1204,6 @@ fn output_elements(
}
if let Some(programs) = rounded_clip_program.clone() {
let window_radius = state.theme_settings.window_radius * decoration_progress;
let shadow_offset_y = state.theme_settings.shadow_offset_y;
let shadow_blur = state.theme_settings.shadow_blur;
let shadow_opacity = state.theme_settings.shadow_opacity;
@@ -1299,6 +1335,21 @@ fn output_elements(
} else {
state.window_resize_fills.remove(&id);
}
if let Some(surface) = material_surface
&& let Some((background, _)) = material_element(
state,
renderer,
output,
surface,
constrain,
window_radius as f32,
0,
constrain,
close_alpha,
)
{
elements.push(background);
}
if let Some(shadow) = shadow {
elements.push(shadow.into());
}
@@ -2349,7 +2400,7 @@ fn append_material_surface(
.enumerate()
.filter_map(|(index, (rect, radius))| {
material_element(
state, renderer, output, surface, *rect, *radius, index, geometry,
state, renderer, output, surface, *rect, *radius, index, geometry, 1.0,
)
})
.collect();
@@ -2416,6 +2467,7 @@ fn material_element(
radius: f32,
index: usize,
capture_geometry: Rectangle<i32, Logical>,
alpha: f32,
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let (role, generation) = crate::effects::surface_role(surface)?;
let material = crate::effects::resolve_material(
@@ -2423,7 +2475,7 @@ fn material_element(
state.theme_settings.material_style,
state.theme_settings.shell_opacity as f32,
);
let presentation_alpha = crate::effects::surface_opacity(surface);
let presentation_alpha = crate::effects::surface_opacity(surface) * alpha;
let mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale();
let transform = output.current_transform().invert();
@@ -2455,7 +2507,7 @@ fn material_element(
.to_i32_ceil();
let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
let sample_physical = sample_geometry.to_physical_precise_round(scale);
let parameters = MaterialParameters {
let mut parameters = MaterialParameters {
presentation_alpha,
background_opacity,
blur: (blur * scale) as f32,
@@ -2476,12 +2528,7 @@ fn material_element(
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
geometry,
tint: [
red,
green,
blue,
material.opacity * if blur > 0.0 { 1.0 } else { presentation_alpha },
],
tint: [red, green, blue, material.opacity * presentation_alpha],
generation,
opaque: material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0,
visible_framebuffer: framebuffer_clip_rect(
@@ -2495,17 +2542,24 @@ fn material_element(
let blur_program = (blur > 0.0)
.then(|| blur_program(state, renderer))
.flatten();
let capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer);
let capture_size = Size::from((capture_rect[2], capture_rect[3]));
let buffers = state.material_buffers.entry(surface.clone()).or_default();
let capture = if let Some(blur_program) = blur_program {
if buffers.captures.get(&context).is_none_or(|c| {
c.geometry != sample_geometry
|| c.texture.size() != Size::from((sample_physical.size.w, sample_physical.size.h))
}) {
if buffers
.captures
.get(&context)
.is_none_or(|c| c.geometry != sample_geometry || c.texture.size() != capture_size)
{
let texture = Offscreen::<GlesTexture>::create_buffer(
renderer,
Fourcc::Abgr8888,
(sample_physical.size.w, sample_physical.size.h).into(),
capture_size,
)
.map_err(|error| {
tracing::warn!(%error, ?sample_physical, "backdrop capture allocation failed");
error
})
.ok();
if let Some(texture) = texture {
buffers.captures.insert(
@@ -2529,6 +2583,9 @@ fn material_element(
buffers.captures.remove(&context);
None
};
if capture.is_some() {
parameters.tint[3] = BLURRED_MATERIAL_TINT;
}
let make_element = || {
if let Some((capture, program)) = &capture {
MaterialElement::Blur(BlurRenderElement::new(
@@ -2601,7 +2658,7 @@ fn material_element(
}
fn material_blur_radius(style: crate::config::MaterialStyle, opacity: f32, radius: f64) -> f64 {
if style == crate::config::MaterialStyle::Translucent && opacity > 0.0 && opacity < 1.0 {
if style == crate::config::MaterialStyle::Translucent && opacity > 0.0 {
radius
} else {
0.0
@@ -2617,6 +2674,7 @@ fn blur_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<BlurP
UniformName::new("visible_rect", UniformType::_4f),
UniformName::new("material_radius", UniformType::_1f),
UniformName::new("texture_size", UniformType::_2f),
UniformName::new("capture_origin", UniformType::_2f),
UniformName::new("blur_radius", UniformType::_1f),
UniformName::new("presentation_alpha", UniformType::_1f),
UniformName::new("background_opacity", UniformType::_1f),
@@ -2641,10 +2699,12 @@ fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
Uniform::new("material_radius", p.radius).into_owned(),
Uniform::new(
"texture_size",
[
p.sample_physical.size.w as f32,
p.sample_physical.size.h as f32,
],
[p.sample_framebuffer[2], p.sample_framebuffer[3]],
)
.into_owned(),
Uniform::new(
"capture_origin",
[p.sample_framebuffer[0], p.sample_framebuffer[1]],
)
.into_owned(),
Uniform::new("blur_radius", p.blur).into_owned(),
@@ -3101,7 +3161,7 @@ mod tests {
}
#[test]
fn fully_transparent_and_opaque_materials_skip_blur() {
fn translucent_materials_blur_at_full_opacity() {
use crate::config::MaterialStyle;
assert_eq!(
super::material_blur_radius(MaterialStyle::Translucent, 0.0, 18.0),
@@ -3109,7 +3169,7 @@ mod tests {
);
assert_eq!(
super::material_blur_radius(MaterialStyle::Translucent, 1.0, 18.0),
0.0
18.0
);
assert_eq!(
super::material_blur_radius(MaterialStyle::Translucent, 0.79, 18.0),
+55 -1
View File
@@ -1,4 +1,4 @@
//! Generated bindings for Ferese-private Wayland protocols.
//! Generated bindings for Ferese Wayland protocols.
#![forbid(unsafe_code)]
@@ -56,6 +56,60 @@ pub mod effects {
}
}
/// Application dialog material protocol.
pub mod material {
/// Version 1 of the application dialog material protocol.
pub mod v1 {
#[cfg(feature = "client")]
pub use generated::client;
#[cfg(feature = "server")]
pub use generated::server;
mod generated {
#![allow(
dead_code,
non_camel_case_types,
non_upper_case_globals,
non_snake_case,
unused_imports,
unused_unsafe,
unused_variables,
clippy::all
)]
#[cfg(feature = "client")]
pub mod client {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("../../protocols/ferese-material-v1.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("../../protocols/ferese-material-v1.xml");
}
#[cfg(feature = "server")]
pub mod server {
use wayland_server;
use wayland_server::protocol::*;
pub mod __interfaces {
use wayland_server::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("../../protocols/ferese-material-v1.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_server_code!("../../protocols/ferese-material-v1.xml");
}
}
}
}
/// Private shell control and managed-window metadata protocol.
pub mod shell {
/// Version 1 of the Ferese shell control protocol.
+4
View File
@@ -6,4 +6,8 @@ license.workspace = true
rust-version = "1.95"
[dependencies]
jiff.workspace = true
ferese-config = { path = "../ferese-config" }
libcosmic.workspace = true
ferese-protocols = { path = "../ferese-protocols", default-features = false, features = ["client"] }
wayland-client.workspace = true
+32
View File
@@ -0,0 +1,32 @@
# Ferese theme
The shared native UI theme for the shell, settings, lock screen, authentication
prompt and screen-sharing/recording chooser.
- `Palette` resolves KDL colors and corners and builds the native toolkit theme.
- `PRESETS` defines the six built-in color schemes.
- `font` and `text` share bounded font-name storage and explicit font selection.
- `controls` supplies surfaces, navigation, buttons and input styles.
- `menus` supplies headings, rows, sections, separators, switches and slider styles.
- `calendar` supplies date/time labels, stacked clock digits and calendar grids.
Callers supply the date and navigation controls.
- `icons` owns the shared SVG assets, outline icons, sizing and semantic tinting.
- Contrast helpers keep filled controls readable in every interaction state.
- `material::ModalMaterial` attaches a dialog to the compositor's shell material.
Keep the attachment alive with its window and make the client background
transparent after attachment succeeds. Other compositors retain the client fill.
Applications own layout, data, actions and animation timing. Pass their current
palette and animation progress into the shared components; do not build another
native theme or copy a common control style into an application.
```rust
let palette = ferese_theme::Palette::from_document(Some(&document));
let native_theme = palette.native_theme();
let input_style = ferese_theme::controls::settings_input(palette);
let font = ferese_theme::font(Some("Inter"));
let label = ferese_theme::text("Appearance", font);
```
The compositor owns backdrop blur and material rendering. This crate does not
provide a client-side imitation of those effects.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+53
View File
@@ -0,0 +1,53 @@
use crate::{composite, foreground};
use cosmic::{
iced::{Background, Color},
theme,
widget::button,
};
#[derive(Clone, Copy)]
pub(crate) enum State {
Active,
Hovered,
Pressed,
Disabled,
}
pub(crate) fn accent_button_style(
theme: &cosmic::Theme,
state: State,
focused: bool,
) -> button::Style {
let native = theme.cosmic();
let component = &native.accent_button;
let fill: Color = match state {
State::Active => component.base.into(),
State::Hovered => component.hover.into(),
State::Pressed => component.pressed.into(),
State::Disabled => Color {
a: 0.5,
..component.base.into()
},
};
let background = composite(fill, native.primary(false).base.into());
let foreground = foreground(background, component.on.into());
button::Style {
background: Some(Background::Color(background)),
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: native.corner_radii.radius_xl.into(),
outline_width: if focused { 1. } else { 0. },
outline_color: foreground,
..Default::default()
}
}
/// Filled accent buttons and selected rows share this per-state contrast policy.
pub fn accent_button() -> theme::Button {
theme::Button::Custom {
active: Box::new(|focused, theme| accent_button_style(theme, State::Active, focused)),
hovered: Box::new(|focused, theme| accent_button_style(theme, State::Hovered, focused)),
pressed: Box::new(|focused, theme| accent_button_style(theme, State::Pressed, focused)),
disabled: Box::new(|theme| accent_button_style(theme, State::Disabled, false)),
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Date/time labels and a calendar grid; callers own navigation and popup behavior.
use cosmic::{
Element,
iced::{Alignment, Background, Border, Length},
theme,
widget::{column, container, row},
};
use jiff::Zoned;
pub 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" }
)
}
pub fn stacked_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()])
}
pub fn 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)
}
pub fn grid<'a, M: Clone + 'a>(
today: jiff::civil::Date,
offset: i32,
palette: crate::Palette,
font: cosmic::font::Font,
opacity: f32,
navigation: [Element<'a, M>; 3],
) -> Element<'a, M> {
let months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
let (first, start, days) = month(today, offset);
let year = first.year();
let month = first.month();
let primary = palette.text.scale_alpha(opacity);
let muted = palette.muted.scale_alpha(opacity);
let [previous, today_button, next] = navigation;
let header = row![
crate::text(format!("{} {year}", months[(month - 1) as usize]), font)
.size(15)
.width(Length::Fill),
previous,
today_button,
next,
]
.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(
crate::text(label, font)
.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(
crate::text(label, font)
.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(palette.accent.scale_alpha(0.2 * opacity))),
border: Border {
radius: palette.radius.min(12.).into(),
..Default::default()
},
..Default::default()
})),
);
}
grid = grid.push(dates);
}
grid.into()
}
#[cfg(test)]
mod tests {
#[test]
fn pixel_clock_stacks_only_hour_and_minute_formats() {
assert_eq!(
super::stacked_digits("2:45 pm"),
Some(["02".into(), "45".into()])
);
assert_eq!(
super::stacked_digits("14:45"),
Some(["14".into(), "45".into()])
);
assert!(super::stacked_digits("14:45:30").is_none());
assert!(super::stacked_digits("Today 14:45").is_none());
assert!(super::stacked_digits("14:45 pm extra").is_none());
}
#[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);
}
}
#[test]
fn calendar_aligns_leap_months_and_year_boundaries() {
let february = jiff::civil::Date::new(2024, 2, 15).unwrap();
let (first, start, days) = super::month(february, 0);
assert_eq!((first.year(), first.month(), start, days), (2024, 2, 3, 29));
let (first, start, days) = super::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) = super::month(june, 0);
assert_eq!((start, days), (6, 30));
}
}
+250
View File
@@ -0,0 +1,250 @@
use cosmic::iced::Color;
pub fn luminance(color: Color) -> f32 {
let linear = |v: f32| {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
pub fn contrast(a: Color, b: Color) -> f32 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// Resolve a control fill against its surface before selecting its foreground.
/// The returned fill is opaque, so wallpaper and translucent panels cannot change
/// the contrast of a small, filled control.
pub fn composite(fill: Color, surface: Color) -> Color {
let a = fill.a.clamp(0., 1.);
Color::from_rgb(
fill.r * a + surface.r * (1. - a),
fill.g * a + surface.g * (1. - a),
fill.b * a + surface.b * (1. - a),
)
}
/// Preserve the user's preferred color when it passes normal-text contrast.
/// Otherwise black or white always provides at least 4.5:1 on an opaque sRGB fill.
pub fn foreground(background: Color, preferred: Color) -> Color {
let preferred = composite(preferred, background);
if contrast(background, preferred) >= 4.5 {
return preferred;
}
if contrast(background, Color::BLACK) >= contrast(background, Color::WHITE) {
Color::BLACK
} else {
Color::WHITE
}
}
/// Prefer the theme's foreground for filled controls by adjusting the accent's
/// brightness until the text has enough contrast.
pub fn accent_pair(background: Color, preferred: Color) -> (Color, Color) {
let on = composite(preferred, background);
let shade = if luminance(on) > luminance(background) {
Color::BLACK
} else {
Color::WHITE
};
for step in 0..=65 {
let fill = composite(
Color {
a: step as f32 / 100.,
..shade
},
background,
);
if contrast(fill, on) >= 4.5 {
return (fill, on);
}
}
(background, foreground(background, preferred))
}
/// Resolve configured accent transparency before passing an opaque color to a toolkit.
pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb {
rgba(composite(accent, surface)).color
}
/// Supply the same semantic on-accent colors to native toolkit controls.
pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] {
let (base, on) = accent_pair(composite(component.base.into(), surface), preferred);
component.hover = rgba(readable_fill(
composite(component.hover.into(), surface),
base,
on,
));
component.pressed = rgba(readable_fill(
composite(component.pressed.into(), surface),
base,
on,
));
component.selected = rgba(readable_fill(
composite(component.selected.into(), surface),
base,
on,
));
component.base = rgba(base);
component.on = rgba(on);
component.selected_text = rgba(foreground(
composite(component.selected.into(), surface),
on,
));
component.disabled = rgba(composite(Color { a: 0.5, ..base }, surface));
component.on_disabled = rgba(foreground(composite(Color { a: 0.5, ..base }, surface), on));
}
}
// Native controls reuse one foreground for normal/hover/pressed. Keep their
// hover feedback, but pull an unreadable fill toward the readable normal fill.
fn readable_fill(candidate: Color, base: Color, on: Color) -> Color {
for step in 0..=20 {
let t = step as f32 / 20.;
let fill = Color::from_rgb(
candidate.r + (base.r - candidate.r) * t,
candidate.g + (base.g - candidate.g) * t,
candidate.b + (base.b - candidate.b) * t,
);
if contrast(fill, on) >= 4.5 {
return fill;
}
}
base
}
fn rgba(c: Color) -> cosmic::cosmic_theme::palette::Srgba {
cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, c.a)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::button::{State, accent_button_style};
use cosmic::iced::Background;
#[test]
fn reference_contrast_values_use_linear_srgb() {
assert!((contrast(Color::BLACK, Color::WHITE) - 21.).abs() < 0.00001);
assert!((contrast(Color::from_rgb8(128, 128, 128), Color::BLACK) - 5.31721).abs() < 0.0001);
}
#[test]
fn foreground_preserves_readable_theme_text_and_rejects_low_contrast() {
let preferred = Color::from_rgb8(240, 242, 246);
assert_eq!(foreground(Color::BLACK, preferred), preferred);
assert_eq!(foreground(Color::WHITE, preferred), Color::BLACK);
assert_eq!(
foreground(Color::from_rgb8(61, 123, 230), preferred),
Color::BLACK
);
}
#[test]
fn all_accent_hues_and_opacities_have_readable_text() {
for r in (0..=255).step_by(17) {
for g in (0..=255).step_by(17) {
for b in (0..=255).step_by(17) {
for surface in [Color::BLACK, Color::WHITE] {
for alpha in [0., 0.2, 0.5, 1.] {
let fill = composite(Color::from_rgba8(r, g, b, alpha), surface);
let (fill, on) = accent_pair(fill, Color::from_rgb8(120, 130, 140));
assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}");
}
}
}
}
}
}
#[test]
fn blue_accent_preserves_light_theme_text_in_every_state() {
let preferred = Color::from_rgb8(244, 247, 251);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(Color::from_rgb8(61, 123, 230)).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let actual = accent_button_style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
assert_eq!(actual.icon_color, Some(preferred));
}
}
#[test]
fn gruvbox_orange_button_keeps_its_light_theme_text_when_enabled() {
let preferred = Color::from_rgb8(235, 219, 178);
let accent = Color::from_rgb8(254, 128, 25);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [State::Active, State::Hovered, State::Pressed] {
let actual = accent_button_style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
let Some(Background::Color(fill)) = actual.background else {
panic!("No button fill")
};
assert!(contrast(fill, preferred) >= 4.5);
}
}
#[test]
fn native_button_states_keep_text_and_icons_readable() {
for accent in [
Color::BLACK,
Color::WHITE,
Color::from_rgb8(61, 123, 230),
Color::from_rgb8(255, 220, 0),
] {
for mut native in [
cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build(),
cosmic::cosmic_theme::ThemeBuilder::light()
.accent(rgba(accent).color)
.build(),
] {
apply(&mut native, Color::from_rgb8(244, 247, 251));
for component in [&native.accent, &native.accent_button] {
for fill in [component.base, component.hover, component.pressed] {
assert!(contrast(fill.into(), component.on.into()) >= 4.5);
}
assert!(
contrast(component.selected.into(), component.selected_text.into()) >= 4.5
);
assert!(
contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5
);
}
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let style = accent_button_style(&theme, state, false);
let Some(Background::Color(fill)) = style.background else {
panic!("No fill")
};
assert_eq!(style.text_color, style.icon_color);
assert!(contrast(fill, style.text_color.unwrap()) >= 4.5);
}
}
}
}
}
+229
View File
@@ -0,0 +1,229 @@
use crate::{Palette, mix};
use cosmic::{
iced::{Background, Border, Color, Vector},
theme,
widget::{self, button, container},
};
pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
theme::Container::custom(move |_| surface_appearance(background, radius))
}
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 settings_input(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: p.radius.min(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, crate::surface_shade(p.sidebar), 0.06)
} else if navigation {
p.sidebar
} else {
p.card
};
let (background, on) = if selected {
crate::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: p.radius.min(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 authentication_input(palette: Palette) -> 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 lock_input(
radius: f32,
accent: cosmic::iced::Color,
surface: cosmic::iced::Color,
) -> theme::TextInput {
let appearance = move |focused: bool| widget::text_input::Appearance {
background: cosmic::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 {
cosmic::iced::Color::WHITE.scale_alpha(0.15)
},
icon_color: Some(cosmic::iced::Color::WHITE.scale_alpha(0.8)),
text_color: Some(cosmic::iced::Color::WHITE),
placeholder_color: cosmic::iced::Color::WHITE.scale_alpha(0.55),
selected_text_color: cosmic::iced::Color::WHITE,
selected_fill: accent.scale_alpha(0.5),
label_color: cosmic::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)),
}
}
pub fn shell_button(
foreground: Color,
selected: bool,
opacity: f32,
radius: f32,
progress: f32,
pressed: bool,
) -> button::Style {
button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: radius.into(),
background: Some(Background::Color(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()
}
}
pub fn surface_appearance(background: Color, radius: f32) -> container::Style {
container::Style {
background: Some(Background::Color(background)),
border: Border {
radius: radius.into(),
..Default::default()
},
..Default::default()
}
}
pub fn notification_button(
foreground: Color,
hover: Color,
radius: f32,
filled: bool,
) -> theme::Button {
let style = move |active: bool| button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
background: (active || filled).then_some(Background::Color(hover)),
border_radius: radius.into(),
..Default::default()
};
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 filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32) -> theme::Button {
let paint = move |outline| button::Style {
background: Some(Color { a: opacity, ..fill }.into()),
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: radius.into(),
outline_width: outline,
outline_color: foreground,
..Default::default()
};
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.)),
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Canonical SVG assets, sizing and semantic tint rules for native Ferese UIs.
use cosmic::{
iced::{Color, ContentFit, Length},
theme,
widget::icon,
};
pub const ARROW: &[u8] = include_bytes!("../assets/icons/arrow.svg");
pub const BATTERY: &[u8] = include_bytes!("../assets/icons/battery.svg");
pub const CHECK: &[u8] = include_bytes!("../assets/icons/check.svg");
pub const DISPLAY: &[u8] = include_bytes!("../assets/icons/display.svg");
pub const FERESE: &[u8] = include_bytes!("../assets/icons/ferese.svg");
pub const OVERVIEW: &[u8] = include_bytes!("../assets/icons/overview.svg");
pub const AIRPLANE: &[u8] = include_bytes!("../assets/icons/status/airplane.svg");
pub const BATTERY_25: &[u8] = include_bytes!("../assets/icons/status/battery-25.svg");
pub const BATTERY_50: &[u8] = include_bytes!("../assets/icons/status/battery-50.svg");
pub const BATTERY_75: &[u8] = include_bytes!("../assets/icons/status/battery-75.svg");
pub const BATTERY_CHARGING: &[u8] = include_bytes!("../assets/icons/status/battery-charging.svg");
pub const BATTERY_EMPTY: &[u8] = include_bytes!("../assets/icons/status/battery-empty.svg");
pub const BATTERY_FULL: &[u8] = include_bytes!("../assets/icons/status/battery-full.svg");
pub const BLUETOOTH_CONNECTED: &[u8] =
include_bytes!("../assets/icons/status/bluetooth-connected.svg");
pub const BLUETOOTH_OFF: &[u8] = include_bytes!("../assets/icons/status/bluetooth-off.svg");
pub const BLUETOOTH_ON: &[u8] = include_bytes!("../assets/icons/status/bluetooth-on.svg");
pub const BRIGHTNESS: &[u8] = include_bytes!("../assets/icons/status/brightness.svg");
pub const CALENDAR: &[u8] = include_bytes!("../assets/icons/status/calendar.svg");
pub const CHEVRON_DOWN: &[u8] = include_bytes!("../assets/icons/status/chevron-down.svg");
pub const CHEVRON_UP: &[u8] = include_bytes!("../assets/icons/status/chevron-up.svg");
pub const CLOSE: &[u8] = include_bytes!("../assets/icons/status/close.svg");
pub const CONTROL_CENTER: &[u8] = include_bytes!("../assets/icons/status/control-center.svg");
pub const DND: &[u8] = include_bytes!("../assets/icons/status/dnd.svg");
pub const ETHERNET: &[u8] = include_bytes!("../assets/icons/status/ethernet.svg");
pub const LOCK: &[u8] = include_bytes!("../assets/icons/status/lock.svg");
pub const MIC_MUTE: &[u8] = include_bytes!("../assets/icons/status/mic-mute.svg");
pub const MIC: &[u8] = include_bytes!("../assets/icons/status/mic.svg");
pub const MOON: &[u8] = include_bytes!("../assets/icons/status/moon.svg");
pub const NOTIFICATIONS_OFF: &[u8] = include_bytes!("../assets/icons/status/notifications-off.svg");
pub const NOTIFICATIONS: &[u8] = include_bytes!("../assets/icons/status/notifications.svg");
pub const POWER_BALANCED: &[u8] = include_bytes!("../assets/icons/status/power-balanced.svg");
pub const POWER_OFF: &[u8] = include_bytes!("../assets/icons/status/power-off.svg");
pub const POWER_PERFORMANCE: &[u8] = include_bytes!("../assets/icons/status/power-performance.svg");
pub const POWER_SAVER: &[u8] = include_bytes!("../assets/icons/status/power-saver.svg");
pub const POWER: &[u8] = include_bytes!("../assets/icons/status/power.svg");
pub const RECORD_CANCEL: &[u8] = include_bytes!("../assets/icons/status/record-cancel.svg");
pub const RECORD_SAVING: &[u8] = include_bytes!("../assets/icons/status/record-saving.svg");
pub const RECORD_STOP: &[u8] = include_bytes!("../assets/icons/status/record-stop.svg");
pub const RECORD: &[u8] = include_bytes!("../assets/icons/status/record.svg");
pub const RESTART: &[u8] = include_bytes!("../assets/icons/status/restart.svg");
pub const SETTINGS: &[u8] = include_bytes!("../assets/icons/status/settings.svg");
pub const SUSPEND: &[u8] = include_bytes!("../assets/icons/status/suspend.svg");
pub const TRASH: &[u8] = include_bytes!("../assets/icons/status/trash.svg");
pub const VOLUME_HIGH: &[u8] = include_bytes!("../assets/icons/status/volume-high.svg");
pub const VOLUME_LOW: &[u8] = include_bytes!("../assets/icons/status/volume-low.svg");
pub const VOLUME_MEDIUM: &[u8] = include_bytes!("../assets/icons/status/volume-medium.svg");
pub const VOLUME_MUTE: &[u8] = include_bytes!("../assets/icons/status/volume-mute.svg");
pub const WIFI_FULL: &[u8] = include_bytes!("../assets/icons/status/wifi-full.svg");
pub const WIFI_LOW: &[u8] = include_bytes!("../assets/icons/status/wifi-low.svg");
pub const WIFI_MEDIUM: &[u8] = include_bytes!("../assets/icons/status/wifi-medium.svg");
pub const WIFI_OFF: &[u8] = include_bytes!("../assets/icons/status/wifi-off.svg");
pub const VOLUME: &[u8] = include_bytes!("../assets/icons/volume.svg");
pub const WIFI: &[u8] = include_bytes!("../assets/icons/wifi.svg");
pub const APPLICATION: &[u8] = include_bytes!("../../../packaging/icons/ferese.svg");
pub fn tinted(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon {
accented(source, size, foreground, foreground)
}
pub fn symbolic(source: &'static [u8], size: u16) -> icon::Icon {
icon::from_svg_bytes(source)
.symbolic(true)
.icon()
.size(size)
}
pub fn accented(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(tint_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 }
}))
}
pub fn tint_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()
}
pub fn outline(path: &str, tint: Color, size: u16) -> 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="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>"##,
);
icon::from_svg_bytes(tint_svg(svg.as_bytes(), tint, tint))
.symbolic(false)
.icon()
.size(size)
}
#[cfg(test)]
mod tests {
use cosmic::iced::Color;
#[test]
fn icons_use_theme_accent_and_preserve_battery_status_colors() {
for (source, accent) in [
(super::BATTERY_FULL, "#63c168"),
(super::BATTERY_50, "#3d7be6"),
(super::BATTERY_25, "#e0654f"),
(super::WIFI_FULL, "#3d7be6"),
(super::CONTROL_CENTER, "#3d7be6"),
] {
let svg = String::from_utf8(super::tint_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
}));
}
}
}
+16 -291
View File
@@ -1,292 +1,17 @@
//! Ferese owns foreground contrast; toolkit defaults must not choose it separately.
use cosmic::{
iced::{Background, Color},
theme,
widget::button,
};
//! Shared colors, typography, controls and compositor materials for Ferese applications.
mod button;
pub mod calendar;
mod contrast;
pub mod controls;
pub mod icons;
pub mod material;
pub mod menus;
mod palette;
mod presets;
mod typography;
pub fn luminance(color: Color) -> f32 {
let linear = |v: f32| {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
}
pub fn contrast(a: Color, b: Color) -> f32 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// Resolve a control fill against its surface before selecting its foreground.
/// The returned fill is opaque, so wallpaper and translucent panels cannot change
/// the contrast of a small, filled control.
pub fn composite(fill: Color, surface: Color) -> Color {
let a = fill.a.clamp(0., 1.);
Color::from_rgb(
fill.r * a + surface.r * (1. - a),
fill.g * a + surface.g * (1. - a),
fill.b * a + surface.b * (1. - a),
)
}
/// Preserve the user's preferred color when it passes normal-text contrast.
/// Otherwise black or white always provides at least 4.5:1 on an opaque sRGB fill.
pub fn foreground(background: Color, preferred: Color) -> Color {
let preferred = composite(preferred, background);
if contrast(background, preferred) >= 4.5 {
return preferred;
}
if contrast(background, Color::BLACK) >= contrast(background, Color::WHITE) {
Color::BLACK
} else {
Color::WHITE
}
}
/// Prefer the theme's foreground for filled controls by adjusting the accent's
/// brightness until the text has enough contrast.
pub fn accent_pair(background: Color, preferred: Color) -> (Color, Color) {
let on = composite(preferred, background);
let shade = if luminance(on) > luminance(background) {
Color::BLACK
} else {
Color::WHITE
};
for step in 0..=65 {
let fill = composite(
Color {
a: step as f32 / 100.,
..shade
},
background,
);
if contrast(fill, on) >= 4.5 {
return (fill, on);
}
}
(background, foreground(background, preferred))
}
/// Resolve configured accent transparency before passing an opaque color to a toolkit.
pub fn accent_color(accent: Color, surface: Color) -> cosmic::cosmic_theme::palette::Srgb {
rgba(composite(accent, surface)).color
}
/// Supply the same semantic on-accent colors to native toolkit controls.
pub fn apply(native: &mut cosmic::cosmic_theme::Theme, preferred: Color) {
let surface: Color = native.primary(false).base.into();
for component in [&mut native.accent, &mut native.accent_button] {
let (base, on) = accent_pair(composite(component.base.into(), surface), preferred);
component.hover = rgba(readable_fill(
composite(component.hover.into(), surface),
base,
on,
));
component.pressed = rgba(readable_fill(
composite(component.pressed.into(), surface),
base,
on,
));
component.selected = rgba(readable_fill(
composite(component.selected.into(), surface),
base,
on,
));
component.base = rgba(base);
component.on = rgba(on);
component.selected_text = rgba(foreground(
composite(component.selected.into(), surface),
on,
));
component.disabled = rgba(composite(Color { a: 0.5, ..base }, surface));
component.on_disabled = rgba(foreground(composite(Color { a: 0.5, ..base }, surface), on));
}
}
// Native controls reuse one foreground for normal/hover/pressed. Keep their
// hover feedback, but pull an unreadable fill toward the readable normal fill.
fn readable_fill(candidate: Color, base: Color, on: Color) -> Color {
for step in 0..=20 {
let t = step as f32 / 20.;
let fill = Color::from_rgb(
candidate.r + (base.r - candidate.r) * t,
candidate.g + (base.g - candidate.g) * t,
candidate.b + (base.b - candidate.b) * t,
);
if contrast(fill, on) >= 4.5 {
return fill;
}
}
base
}
fn rgba(c: Color) -> cosmic::cosmic_theme::palette::Srgba {
cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, c.a)
}
#[derive(Clone, Copy)]
enum State {
Active,
Hovered,
Pressed,
Disabled,
}
fn style(theme: &cosmic::Theme, state: State, focused: bool) -> button::Style {
let native = theme.cosmic();
let component = &native.accent_button;
let fill: Color = match state {
State::Active => component.base.into(),
State::Hovered => component.hover.into(),
State::Pressed => component.pressed.into(),
State::Disabled => Color {
a: 0.5,
..component.base.into()
},
};
let background = composite(fill, native.primary(false).base.into());
let foreground = foreground(background, component.on.into());
button::Style {
background: Some(Background::Color(background)),
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: native.corner_radii.radius_xl.into(),
outline_width: if focused { 1. } else { 0. },
outline_color: foreground,
..Default::default()
}
}
/// Filled accent buttons and selected rows share this per-state contrast policy.
pub fn accent_button() -> theme::Button {
theme::Button::Custom {
active: Box::new(|focused, theme| style(theme, State::Active, focused)),
hovered: Box::new(|focused, theme| style(theme, State::Hovered, focused)),
pressed: Box::new(|focused, theme| style(theme, State::Pressed, focused)),
disabled: Box::new(|theme| style(theme, State::Disabled, false)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reference_contrast_values_use_linear_srgb() {
assert!((contrast(Color::BLACK, Color::WHITE) - 21.).abs() < 0.00001);
assert!((contrast(Color::from_rgb8(128, 128, 128), Color::BLACK) - 5.31721).abs() < 0.0001);
}
#[test]
fn foreground_preserves_readable_theme_text_and_rejects_low_contrast() {
let preferred = Color::from_rgb8(240, 242, 246);
assert_eq!(foreground(Color::BLACK, preferred), preferred);
assert_eq!(foreground(Color::WHITE, preferred), Color::BLACK);
assert_eq!(
foreground(Color::from_rgb8(61, 123, 230), preferred),
Color::BLACK
);
}
#[test]
fn all_accent_hues_and_opacities_have_readable_text() {
for r in (0..=255).step_by(17) {
for g in (0..=255).step_by(17) {
for b in (0..=255).step_by(17) {
for surface in [Color::BLACK, Color::WHITE] {
for alpha in [0., 0.2, 0.5, 1.] {
let fill = composite(Color::from_rgba8(r, g, b, alpha), surface);
let (fill, on) = accent_pair(fill, Color::from_rgb8(120, 130, 140));
assert!(contrast(fill, on) >= 4.5, "{fill:?}: {on:?}");
}
}
}
}
}
}
#[test]
fn blue_accent_preserves_light_theme_text_in_every_state() {
let preferred = Color::from_rgb8(244, 247, 251);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(Color::from_rgb8(61, 123, 230)).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let actual = style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
assert_eq!(actual.icon_color, Some(preferred));
}
}
#[test]
fn gruvbox_orange_button_keeps_its_light_theme_text_when_enabled() {
let preferred = Color::from_rgb8(235, 219, 178);
let accent = Color::from_rgb8(254, 128, 25);
let mut native = cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build();
apply(&mut native, preferred);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [State::Active, State::Hovered, State::Pressed] {
let actual = style(&theme, state, false);
assert_eq!(actual.text_color, Some(preferred));
let Some(Background::Color(fill)) = actual.background else {
panic!("No button fill")
};
assert!(contrast(fill, preferred) >= 4.5);
}
}
#[test]
fn native_button_states_keep_text_and_icons_readable() {
for accent in [
Color::BLACK,
Color::WHITE,
Color::from_rgb8(61, 123, 230),
Color::from_rgb8(255, 220, 0),
] {
for mut native in [
cosmic::cosmic_theme::ThemeBuilder::dark()
.accent(rgba(accent).color)
.build(),
cosmic::cosmic_theme::ThemeBuilder::light()
.accent(rgba(accent).color)
.build(),
] {
apply(&mut native, Color::from_rgb8(244, 247, 251));
for component in [&native.accent, &native.accent_button] {
for fill in [component.base, component.hover, component.pressed] {
assert!(contrast(fill.into(), component.on.into()) >= 4.5);
}
assert!(
contrast(component.selected.into(), component.selected_text.into()) >= 4.5
);
assert!(
contrast(component.disabled.into(), component.on_disabled.into()) >= 4.5
);
}
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
for state in [
State::Active,
State::Hovered,
State::Pressed,
State::Disabled,
] {
let style = style(&theme, state, false);
let Some(Background::Color(fill)) = style.background else {
panic!("No fill")
};
assert_eq!(style.text_color, style.icon_color);
assert!(contrast(fill, style.text_color.unwrap()) >= 4.5);
}
}
}
}
}
pub use button::accent_button;
pub use contrast::{accent_color, accent_pair, apply, composite, contrast, foreground, luminance};
pub use palette::{Palette, material_opacity, mix, parse_color, surface_shade};
pub use presets::{PRESETS, Preset};
pub use typography::{font, text};
+121
View File
@@ -0,0 +1,121 @@
use std::sync::Arc;
use ferese_protocols::material::v1::client::{
ferese_material_manager_v1::FereseMaterialManagerV1,
ferese_surface_material_v1::FereseSurfaceMaterialV1,
};
use wayland_client::{
Connection, Dispatch, Proxy, QueueHandle, delegate_noop,
globals::{GlobalListContents, registry_queue_init},
protocol::{wl_registry, wl_surface},
};
/// Keeps a compositor-owned dialog material attached for the window lifetime.
#[derive(Clone, Debug)]
pub struct ModalMaterial {
_binding: Arc<Binding>,
}
#[derive(Debug)]
struct Binding {
connection: Connection,
manager: FereseMaterialManagerV1,
material: FereseSurfaceMaterialV1,
}
impl ModalMaterial {
/// Attach while Iced owns the native window; keep its background transparent on success.
/// Other compositors return an error so the caller can retain its own surface fill.
pub fn attach(window: &dyn cosmic::iced::window::Window) -> Result<Self, String> {
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
let display = window.display_handle().map_err(|error| error.to_string())?;
let handle = window.window_handle().map_err(|error| error.to_string())?;
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) =
(display.as_raw(), handle.as_raw())
else {
return Err("Dialog materials require Wayland".into());
};
// Iced lends live native handles; this backend borrows its connection.
let backend = unsafe {
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
};
let connection = Connection::from_backend(backend);
let id = unsafe {
wayland_client::backend::ObjectId::from_ptr(
wl_surface::WlSurface::interface(),
handle.surface.as_ptr().cast(),
)
}
.map_err(|error| error.to_string())?;
let surface =
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
let (globals, mut queue) =
registry_queue_init::<MaterialState>(&connection).map_err(|error| error.to_string())?;
let qh = queue.handle();
let manager = globals
.bind::<FereseMaterialManagerV1, _, _>(&qh, 1..=1, ())
.map_err(|error| error.to_string())?;
let material = manager.get_surface_material(&surface, &qh, ());
let binding = Binding {
connection,
manager,
material,
};
let mut state = MaterialState::default();
queue
.roundtrip(&mut state)
.map_err(|error| error.to_string())?;
if !state.ready {
return Err("Dialog material attachment was not acknowledged".into());
}
Ok(Self {
_binding: Arc::new(binding),
})
}
}
impl Drop for Binding {
fn drop(&mut self) {
self.material.destroy();
self.manager.destroy();
let _ = self.connection.flush();
}
}
#[derive(Default)]
struct MaterialState {
ready: bool,
}
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for MaterialState {
fn event(
_state: &mut Self,
_registry: &wl_registry::WlRegistry,
_event: wl_registry::Event,
_data: &GlobalListContents,
_connection: &Connection,
_qh: &QueueHandle<Self>,
) {
}
}
impl Dispatch<FereseSurfaceMaterialV1, ()> for MaterialState {
fn event(
state: &mut Self,
_material: &FereseSurfaceMaterialV1,
event: ferese_protocols::material::v1::client::ferese_surface_material_v1::Event,
_data: &(),
_connection: &Connection,
_qh: &QueueHandle<Self>,
) {
if matches!(
event,
ferese_protocols::material::v1::client::ferese_surface_material_v1::Event::Ready
) {
state.ready = true;
}
}
}
delegate_noop!(MaterialState: ignore FereseMaterialManagerV1);
+176
View File
@@ -0,0 +1,176 @@
//! Shared menu layouts and paint; callers own actions and animation timing.
use cosmic::{
Element,
iced::{Alignment, Color, Length},
widget::{button, container, row as iced_row},
};
pub fn button<'a, M: Clone + 'a>(
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
message: M,
font: cosmic::font::Font,
) -> button::Button<'a, M> {
button::custom(crate::text(label, font).size(13))
.padding([6, 8])
.on_press(message)
}
pub fn row<'a, M: 'a>(
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
trailing: Element<'a, M>,
font: cosmic::font::Font,
) -> Element<'a, M> {
iced_row![
crate::text(label, font).width(Length::Fill).size(14),
trailing
]
.align_y(Alignment::Center)
.into()
}
pub fn heading<'a, M: 'a>(
badge: Element<'a, M>,
title: impl Into<std::borrow::Cow<'a, str>> + 'a,
font: cosmic::font::Font,
) -> cosmic::widget::Row<'a, M, cosmic::Theme, cosmic::Renderer> {
iced_row![badge, crate::text(title, font).size(18).width(Length::Fill)]
.spacing(10)
.align_y(Alignment::Center)
}
pub fn badge<'a, M: 'a>(
content: Element<'a, M>,
accent: Color,
opacity: f32,
radius: f32,
) -> Element<'a, M> {
container(content)
.width(36)
.height(36)
.center_x(36)
.center_y(36)
.class(crate::controls::surface(
accent.scale_alpha(0.12 * opacity),
radius.min(20.),
))
.into()
}
pub fn section<'a, M: 'a>(
content: Element<'a, M>,
foreground: Color,
opacity: f32,
radius: f32,
) -> Element<'a, M> {
container(content)
.padding(10)
.width(Length::Fill)
.class(crate::controls::surface(
Color {
a: 0.045 * opacity,
..foreground
},
radius.min(22.),
))
.into()
}
pub fn section_label<'a>(
label: impl Into<std::borrow::Cow<'a, str>> + 'a,
font: cosmic::font::Font,
muted: Color,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
crate::text(label, font)
.size(13)
.class(cosmic::theme::Text::Color(muted))
}
pub fn separator<'a, M: 'a>(foreground: Color, opacity: f32) -> Element<'a, M> {
container(cosmic::widget::Space::new().width(Length::Fill).height(1))
.class(crate::controls::surface(
foreground.scale_alpha(0.12 * opacity),
0.,
))
.into()
}
pub fn switch<'a, M: 'a>(enabled: bool, palette: crate::Palette, opacity: f32) -> Element<'a, M> {
let track = if enabled {
palette.accent
} else {
Color {
a: 0.22,
..palette.text
}
};
let thumb = if enabled {
crate::foreground(track, palette.text)
} else {
palette.text
};
let knob = container(cosmic::widget::Space::new().width(16).height(16))
.width(16)
.height(16)
.class(crate::controls::surface(
Color {
a: opacity,
..thumb
},
palette.radius.min(8.),
));
container(knob)
.width(36)
.height(20)
.padding(2)
.align_x(if enabled {
cosmic::iced::alignment::Horizontal::Right
} else {
cosmic::iced::alignment::Horizontal::Left
})
.class(crate::controls::surface(
track.scale_alpha(opacity),
palette.radius.min(10.),
))
.into()
}
pub fn slider(foreground: Color, opacity: f32, radius: f32) -> cosmic::theme::iced::Slider {
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.,
border: cosmic::iced::Border {
radius: radius.min(2.).into(),
..Default::default()
},
},
handle: Handle {
shape: HandleShape::Rectangle {
width: 10,
height: 10,
border_radius: radius.min(5.).into(),
},
background: foreground.into(),
border_width: 0.,
border_color: Color::TRANSPARENT,
},
breakpoint: Breakpoint {
color: Color::TRANSPARENT,
},
}
});
cosmic::theme::iced::Slider::Custom {
active: style.clone(),
hovered: style.clone(),
dragging: style,
}
}
+189
View File
@@ -0,0 +1,189 @@
use cosmic::iced::Color;
use ferese_config::Document;
#[derive(Clone, Copy, Debug, 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 radius: f32,
}
impl Palette {
pub fn from_document(document: Option<&Document>) -> Self {
let color = |key, fallback| {
document
.and_then(|document| document.get(key))
.and_then(|value| value.as_str())
.and_then(parse_color)
.unwrap_or_else(|| parse_color(fallback).expect("valid default theme color"))
};
let base = color("theme.colors.surface_base", "#111821");
Self {
background: mix(base, surface_shade(base), 0.025),
sidebar: base,
card: mix(base, surface_shade(base), 0.055),
accent: color("theme.colors.accent", "#3D7BE6"),
text: color("theme.colors.text_primary", "#F4F7FB"),
muted: color("theme.colors.text_muted", "#8793A2"),
error: mix(base, Color::from_rgb8(210, 80, 80), 0.2),
radius: document
.and_then(|document| {
document
.get("theme.geometry.shell_radius")
.or_else(|| document.get("appearance.corner_radius"))
})
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite())
.unwrap_or(14.)
.clamp(0., 64.) as f32,
}
}
pub fn flat(mut self) -> Self {
self.background = self.sidebar;
self.card = self.sidebar;
self
}
pub fn native_theme(self) -> cosmic::Theme {
let rgba = |c: Color| cosmic::cosmic_theme::palette::Srgba::new(c.r, c.g, c.b, 1.);
let builder = if crate::luminance(self.sidebar) > 0.5 {
cosmic::cosmic_theme::ThemeBuilder::light()
} else {
cosmic::cosmic_theme::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(self.background))
.primary_container_bg(rgba(self.card))
.text_tint(rgba(self.text).color)
.accent(crate::accent_color(self.accent, self.card))
.build();
crate::apply(&mut native, self.text);
cosmic::Theme::custom(std::sync::Arc::new(native))
}
pub fn application_style(self, background: Color) -> cosmic::iced::theme::Style {
cosmic::iced::theme::Style {
background_color: background,
text_color: self.text,
icon_color: self.text,
}
}
}
pub 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 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,
)
}
pub fn surface_shade(base: Color) -> Color {
if crate::luminance(base) > 0.5 {
Color::BLACK
} else {
Color::WHITE
}
}
pub fn material_opacity(document: Option<&Document>) -> f32 {
let style = document
.and_then(|document| document.get("theme.material.style"))
.and_then(|value| value.as_str())
.unwrap_or("solid");
if style != "translucent" {
return 1.;
}
document
.and_then(|document| document.get("theme.material.opacity"))
.and_then(|value| value.as_f64())
.filter(|value| value.is_finite())
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY)
.clamp(0., 1.) as f32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_preset_builds_an_opaque_control_palette_over_translucent_materials() {
for preset in crate::PRESETS {
let document = Document::parse(&format!(
r##"theme {{
colors {{
surface-base "{}"
accent "{}"
text-primary "{}"
text-muted "{}"
}}
material {{ style translucent; opacity 0.25; }}
geometry {{ shell-radius 0; }}
}}"##,
preset.base, preset.accent, preset.text, preset.muted,
))
.unwrap();
let palette = Palette::from_document(Some(&document));
let native = palette.native_theme();
assert_eq!(material_opacity(Some(&document)), 0.25);
assert_eq!(native.cosmic().primary(false).base.alpha, 1.);
assert_eq!(native.cosmic().corner_radii.radius_m, [0.; 4]);
for component in [&native.cosmic().accent, &native.cosmic().accent_button] {
for fill in [component.base, component.hover, component.pressed] {
assert!(
crate::contrast(fill.into(), component.on.into()) >= 4.5,
"{}",
preset.name
);
}
}
}
}
#[test]
fn solid_materials_ignore_opacity_and_translucent_materials_bound_it() {
for (style, opacity, expected) in [
("solid", 0.4, 1.),
("translucent", 0.4, 0.4),
("translucent", 2., 1.),
("translucent", -1., 0.),
] {
let document = Document::parse(&format!(
"theme {{ material {{ style {style}; opacity {opacity}; }} }}"
))
.unwrap();
assert_eq!(material_opacity(Some(&document)), expected);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
pub struct Preset {
pub name: &'static str,
pub description: &'static str,
pub accent: &'static str,
pub base: &'static str,
pub text: &'static str,
pub muted: &'static str,
pub border: &'static str,
pub 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",
},
];
+30
View File
@@ -0,0 +1,30 @@
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
pub fn font(family: Option<&str>) -> cosmic::font::Font {
let Some(family) = family else {
return cosmic::font::default();
};
static FONTS: OnceLock<Mutex<HashMap<String, cosmic::font::Font>>> = OnceLock::new();
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
if let Some(font) = fonts.get(family) {
return *font;
}
if fonts.len() >= 64 {
return cosmic::font::default();
}
let name = Box::leak(family.to_owned().into_boxed_str());
let font = cosmic::font::Font::with_name(name);
fonts.insert(family.to_owned(), font);
font
}
/// COSMIC's text helper overrides Settings::default_font; set the Ferese font explicitly.
pub fn text<'a>(
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
font: cosmic::font::Font,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
cosmic::widget::text(content).font(font)
}
+1 -1
View File
@@ -134,7 +134,7 @@ these six visually distinct.
| `theme.background.path` | string | bundled Ferese wallpaper | Wallpaper image path; an existing selection overrides the default |
| `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox |
| `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material |
| `theme.material.opacity` | number 0–1 | `0.78` | Shared shell background opacity for bars, menus, popovers, notifications and the sharing dialog. Text/icons stay opaque; 0 and 1 skip blur. Solid mode is always opaque |
| `theme.material.opacity` | number 0–1 | `0.78` | Shared shell background opacity for bars, menus, popovers, notifications and themed dialogs. Text/icons stay opaque; 0 hides the material. Solid mode is always opaque |
| `theme.material.blur_radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables |
| `theme.colors` key | Default | Meaning |
+3 -2
View File
@@ -28,8 +28,9 @@ Use a Wayland screen-sharing or recording source in your application. Ferese
opens a display picker. Select a display and press **Share**. Nothing is selected
by default. Applications that request multiple displays can share up to four.
The picker uses your Ferese theme colors, font, and shell corner radius. Its
background follows `theme.material.style`: opaque in solid mode, or translucent
using the shared **Appearance → Shell opacity** setting (`theme.material.opacity`). Text and icons remain opaque.
background uses the same compositor material as popovers and authentication
prompts: opaque in solid mode, or blurred and translucent using the shared
**Appearance → Shell opacity** and blur settings. Text and icons remain opaque.
Other applications keep a **screen sharing** window with a **Stop sharing** button.
Ferese’s built-in recorder uses the top-bar stop control instead, leaving your
+83 -128
View File
@@ -3,8 +3,9 @@ use cosmic::{
ApplicationExt, Element,
app::{Core, Settings, Task},
iced::{Alignment, Length},
widget::{button, column, container, icon, row, scrollable, text},
widget::{button, column, container, row, scrollable},
};
use ferese_theme::icons::symbolic as glyph;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
@@ -17,6 +18,8 @@ pub struct Prompt {
#[derive(Clone, Debug)]
enum Message {
WindowOpened(cosmic::iced::window::Id),
MaterialAttached(Result<ferese_theme::material::ModalMaterial, String>),
Select(usize),
Share,
Cancel,
@@ -24,10 +27,12 @@ enum Message {
struct Picker {
core: Core,
material: Option<ferese_theme::material::ModalMaterial>,
prompt: Prompt,
selected: Vec<usize>,
background: cosmic::iced::Color,
foreground: cosmic::iced::Color,
font: cosmic::font::Font,
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
@@ -42,15 +47,17 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
}
let (theme, font, background, foreground) = appearance();
let height = 156. + (prompt.sources.len().min(4) as f32 * 56.);
let height = 180. + (prompt.sources.len().min(4) as f32 * 56.);
cosmic::app::run::<Picker>(
Settings::default()
.size(cosmic::iced::Size::new(400., height))
.client_decorations(false)
.transparent(true)
.theme(theme)
.default_font(font)
.default_text_size(14.)
.is_daemon(false),
(prompt, background, foreground),
(prompt, background, foreground, font),
)?;
Ok(())
@@ -58,7 +65,12 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
impl cosmic::Application for Picker {
type Executor = cosmic::executor::Default;
type Flags = (Prompt, cosmic::iced::Color, cosmic::iced::Color);
type Flags = (
Prompt,
cosmic::iced::Color,
cosmic::iced::Color,
cosmic::font::Font,
);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.ScreenShare";
@@ -72,7 +84,7 @@ impl cosmic::Application for Picker {
fn init(
mut core: Core,
(prompt, background, foreground): Self::Flags,
(prompt, background, foreground, font): Self::Flags,
) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
core.window.border_padding = Some(0);
@@ -80,10 +92,12 @@ impl cosmic::Application for Picker {
// No preselected source: clicking Share must be a deliberate choice.
let mut app = Self {
core,
material: None,
prompt,
selected: Vec::new(),
background,
foreground,
font,
};
let task = app
.core
@@ -94,8 +108,27 @@ impl cosmic::Application for Picker {
(app, task)
}
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
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::Select(index) if index < self.prompt.sources.len() => {
if self.selected.contains(&index) {
self.selected.retain(|i| *i != index);
@@ -126,7 +159,11 @@ impl cosmic::Application for Picker {
fn style(&self) -> Option<cosmic::iced::theme::Style> {
Some(cosmic::iced::theme::Style {
background_color: self.background,
background_color: if self.material.is_some() {
cosmic::iced::Color::TRANSPARENT
} else {
self.background
},
text_color: self.foreground,
icon_color: self.foreground,
})
@@ -140,10 +177,10 @@ impl cosmic::Application for Picker {
};
let mut content = column([]).width(Length::Fill).spacing(10).push(
row![
glyph(include_bytes!("../assets/display.svg"), 24),
glyph(ferese_theme::icons::DISPLAY, 24),
column![
text("Choose a display").size(18),
text(format!("Share with {app}")).size(12),
self.text("Choose a display").size(18),
self.text(format!("Share with {app}")).size(12),
]
.spacing(2)
.width(Length::Fill),
@@ -155,10 +192,11 @@ impl cosmic::Application for Picker {
for (index, source) in self.prompt.sources.iter().enumerate() {
let selected = self.selected.contains(&index);
let mut entry = row![
glyph(include_bytes!("../assets/display.svg"), 24),
glyph(ferese_theme::icons::DISPLAY, 24),
column![
text(&source.name).size(14),
text(format!("{} × {}", source.width, source.height)).size(12)
self.text(&source.name).size(14),
self.text(format!("{} × {}", source.width, source.height))
.size(12)
]
.spacing(3)
.width(Length::Fill),
@@ -166,7 +204,7 @@ impl cosmic::Application for Picker {
.spacing(12)
.align_y(Alignment::Center);
if selected {
entry = entry.push(glyph(include_bytes!("../assets/check.svg"), 20));
entry = entry.push(glyph(ferese_theme::icons::CHECK, 20));
}
sources = sources.push(
button::custom(entry)
@@ -184,34 +222,40 @@ impl cosmic::Application for Picker {
(self.prompt.sources.len().min(4) * 56) as f32,
)));
content = content.push(
text("Everything on this display, including notifications, is visible.")
self.text("Everything on this display, including notifications, is visible.")
.size(12)
.width(Length::Fill),
);
let mut share = button::custom(
row![
text("Share"),
glyph(include_bytes!("../assets/arrow.svg"), 16)
]
.spacing(8)
.align_y(Alignment::Center),
row![self.text("Share"), glyph(ferese_theme::icons::ARROW, 16)]
.spacing(8)
.align_y(Alignment::Center),
)
.class(ferese_theme::accent_button())
.padding([6, 16]);
if !self.selected.is_empty() {
share = share.on_press(Message::Share);
}
content =
content.push(row![button::text("Cancel").on_press(Message::Cancel), share].spacing(8));
content = content.push(
row![
button::custom(self.text("Cancel"))
.class(cosmic::theme::Button::Text)
.on_press(Message::Cancel),
share
]
.spacing(8),
);
container(content).padding(14).width(Length::Fill).into()
}
}
fn glyph(source: &'static [u8], size: u16) -> icon::Icon {
icon::from_svg_bytes(source)
.symbolic(true)
.icon()
.size(size)
impl Picker {
fn text<'a>(
&self,
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
ferese_theme::text(content, self.font)
}
}
fn appearance() -> (
@@ -221,110 +265,21 @@ fn appearance() -> (
cosmic::iced::Color,
) {
let document = ferese_config::config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| ferese_config::Document::parse(&s).ok());
let string = |key: &str, fallback: &str| {
document
.as_ref()
.and_then(|d| d.get(key))
.and_then(|v| v.as_str())
.unwrap_or(fallback)
.to_owned()
};
let rgba = |key, fallback| {
let s = string(key, fallback);
let parse = |s: &str| {
let hex = s.strip_prefix('#')?;
let n = u32::from_str_radix(hex, 16).ok()?;
Some(match hex.len() {
6 => (n << 8) | 255,
8 => n,
_ => return None,
})
};
let n = parse(&s).unwrap_or_else(|| parse(fallback).unwrap());
cosmic::cosmic_theme::palette::Srgba::new(
((n >> 24) & 255) as f32 / 255.,
((n >> 16) & 255) as f32 / 255.,
((n >> 8) & 255) as f32 / 255.,
(n & 255) as f32 / 255.,
)
};
let background = rgba("theme.colors.surface_base", "#111821");
let foreground = rgba("theme.colors.text_primary", "#F4F7FB");
let accent = rgba("theme.colors.accent", "#3D7BE6");
let radius = document
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|source| ferese_config::Document::parse(&source).ok());
let palette = ferese_theme::Palette::from_document(document.as_ref()).flat();
let family = document
.as_ref()
.and_then(|d| d.get("theme.geometry.shell_radius"))
.and_then(|v| v.as_f64())
.unwrap_or(14.)
.clamp(0., 40.) as f32;
let corners = cosmic::cosmic_theme::CornerRadii {
radius_xs: [radius.min(4.); 4],
radius_s: [radius.min(8.); 4],
radius_m: [radius; 4],
radius_l: [radius; 4],
radius_xl: [radius; 4],
..Default::default()
};
let builder = if background.red + background.green + background.blue > 1.8 {
cosmic::cosmic_theme::ThemeBuilder::light()
} else {
cosmic::cosmic_theme::ThemeBuilder::dark()
};
let mut native = builder
.bg_color(background)
.primary_container_bg(background)
.text_tint(foreground.color)
.accent(ferese_theme::accent_color(accent.into(), background.into()))
.corner_radii(corners)
.build();
ferese_theme::apply(&mut native, foreground.into());
let mut material: cosmic::iced::Color = background.into();
material.a = material_opacity(
&string("theme.material.style", "solid"),
document
.as_ref()
.and_then(|d| d.get("theme.material.opacity"))
.and_then(|v| v.as_f64())
.unwrap_or(ferese_config::DEFAULT_MATERIAL_OPACITY),
);
let theme = cosmic::Theme::custom(std::sync::Arc::new(native));
let family: &'static str =
Box::leak(string("theme.typography.font_family", "Inter").into_boxed_str());
.and_then(|document| document.get("theme.typography.font_family"))
.and_then(|value| value.as_str())
.unwrap_or("Inter");
(
theme,
cosmic::font::Font {
family: cosmic::iced::font::Family::Name(family),
..cosmic::font::default()
},
material,
palette.native_theme(),
ferese_theme::font(Some(family)),
cosmic::iced::Color {
a: 1.,
..foreground.into()
a: ferese_theme::material_opacity(document.as_ref()),
..palette.sidebar
},
palette.text,
)
}
// Only the surface tint is translucent; control fills and foregrounds stay opaque.
fn material_opacity(style: &str, opacity: f64) -> f32 {
if style == "translucent" && opacity.is_finite() {
opacity.clamp(0., 1.) as f32
} else {
1.
}
}
#[cfg(test)]
mod tests {
#[test]
fn material_preserves_solid_and_bounds_translucent_opacity() {
assert_eq!(super::material_opacity("solid", 0.4), 1.);
assert_eq!(super::material_opacity("translucent", 0.4), 0.4);
assert_eq!(super::material_opacity("translucent", 2.), 1.);
assert_eq!(super::material_opacity("translucent", -1.), 0.);
assert_eq!(super::material_opacity("translucent", f64::NAN), 1.);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="ferese_material_v1">
<interface name="ferese_material_manager_v1" version="1">
<description summary="compositor-owned material for application dialogs">
Clients can request the desktop's standard modal material behind their
own surface. The compositor chooses the color, opacity, radius and blur.
This grants no access to backdrop pixels or privileged shell roles.
Clients should leave their surface background transparent after attachment.
</description>
<request name="destroy" type="destructor"/>
<request name="get_surface_material">
<arg name="id" type="new_id" interface="ferese_surface_material_v1"/>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<enum name="error">
<entry name="already_constructed" value="0" summary="surface already has material or effects"/>
</enum>
</interface>
<interface name="ferese_surface_material_v1" version="1">
<description summary="standard material behind one surface">
The material changes only rendering, never focus, input, geometry or stacking.
Destroying this object restores the surface's ordinary rendering.
</description>
<request name="destroy" type="destructor"/>
<event name="ready">
<description summary="material attachment is active"/>
</event>
</interface>
</protocol>
+22 -57
View File
@@ -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> {
+4 -36
View File
@@ -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::*;
+59 -126
View File
@@ -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)
+6 -6
View File
@@ -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(&current);
let native_palette = visuals::Palette::from(&current);
let native_palette = visuals::Palette::from_document(Some(&current.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.));
+18 -299
View File
@@ -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,
+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,