diff --git a/Cargo.lock b/Cargo.lock index 0c02cff..a929224 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1788,7 +1788,11 @@ dependencies = [ name = "ferese-theme" version = "0.1.0" dependencies = [ + "ferese-config", + "ferese-protocols", + "jiff", "libcosmic", + "wayland-client", ] [[package]] diff --git a/README.md b/README.md index fc717ec..f7dc9ff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs index 4cc1060..a8b3310 100644 --- a/compositor/src/effects.rs +++ b/compositor/src/effects.rs @@ -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::(3, ()); + display.create_global::(1, ()); +} + +impl GlobalDispatch for Ferese { + fn bind( + _state: &mut Self, + _display: &DisplayHandle, + _client: &Client, + resource: New, + _global_data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(resource, ()); + } +} + +impl Dispatch 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::().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 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> { diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index fbfe28f..ba59e82 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -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 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 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, + 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::::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 Vec> { 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), diff --git a/crates/ferese-protocols/src/lib.rs b/crates/ferese-protocols/src/lib.rs index b7dfaa0..17bdadf 100644 --- a/crates/ferese-protocols/src/lib.rs +++ b/crates/ferese-protocols/src/lib.rs @@ -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. diff --git a/crates/ferese-theme/Cargo.toml b/crates/ferese-theme/Cargo.toml index 9e99bda..3bd53c3 100644 --- a/crates/ferese-theme/Cargo.toml +++ b/crates/ferese-theme/Cargo.toml @@ -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 diff --git a/crates/ferese-theme/README.md b/crates/ferese-theme/README.md new file mode 100644 index 0000000..ac39216 --- /dev/null +++ b/crates/ferese-theme/README.md @@ -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. diff --git a/portal/assets/arrow.svg b/crates/ferese-theme/assets/icons/arrow.svg similarity index 100% rename from portal/assets/arrow.svg rename to crates/ferese-theme/assets/icons/arrow.svg diff --git a/shell/ferese-shell/assets/icons/battery.svg b/crates/ferese-theme/assets/icons/battery.svg similarity index 100% rename from shell/ferese-shell/assets/icons/battery.svg rename to crates/ferese-theme/assets/icons/battery.svg diff --git a/portal/assets/check.svg b/crates/ferese-theme/assets/icons/check.svg similarity index 100% rename from portal/assets/check.svg rename to crates/ferese-theme/assets/icons/check.svg diff --git a/portal/assets/display.svg b/crates/ferese-theme/assets/icons/display.svg similarity index 100% rename from portal/assets/display.svg rename to crates/ferese-theme/assets/icons/display.svg diff --git a/shell/ferese-shell/assets/icons/ferese.svg b/crates/ferese-theme/assets/icons/ferese.svg similarity index 100% rename from shell/ferese-shell/assets/icons/ferese.svg rename to crates/ferese-theme/assets/icons/ferese.svg diff --git a/shell/ferese-shell/assets/icons/overview.svg b/crates/ferese-theme/assets/icons/overview.svg similarity index 100% rename from shell/ferese-shell/assets/icons/overview.svg rename to crates/ferese-theme/assets/icons/overview.svg diff --git a/shell/ferese-shell/assets/icons/status/airplane.svg b/crates/ferese-theme/assets/icons/status/airplane.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/airplane.svg rename to crates/ferese-theme/assets/icons/status/airplane.svg diff --git a/shell/ferese-shell/assets/icons/status/battery-25.svg b/crates/ferese-theme/assets/icons/status/battery-25.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/battery-25.svg rename to crates/ferese-theme/assets/icons/status/battery-25.svg diff --git a/shell/ferese-shell/assets/icons/status/battery-50.svg b/crates/ferese-theme/assets/icons/status/battery-50.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/battery-50.svg rename to crates/ferese-theme/assets/icons/status/battery-50.svg diff --git a/shell/ferese-shell/assets/icons/status/battery-75.svg b/crates/ferese-theme/assets/icons/status/battery-75.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/battery-75.svg rename to crates/ferese-theme/assets/icons/status/battery-75.svg diff --git a/shell/ferese-shell/assets/icons/status/battery-charging.svg b/crates/ferese-theme/assets/icons/status/battery-charging.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/battery-charging.svg rename to crates/ferese-theme/assets/icons/status/battery-charging.svg diff --git a/shell/ferese-shell/assets/icons/status/battery-empty.svg b/crates/ferese-theme/assets/icons/status/battery-empty.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/battery-empty.svg rename to crates/ferese-theme/assets/icons/status/battery-empty.svg diff --git a/shell/ferese-shell/assets/icons/status/battery-full.svg b/crates/ferese-theme/assets/icons/status/battery-full.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/battery-full.svg rename to crates/ferese-theme/assets/icons/status/battery-full.svg diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-connected.svg b/crates/ferese-theme/assets/icons/status/bluetooth-connected.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/bluetooth-connected.svg rename to crates/ferese-theme/assets/icons/status/bluetooth-connected.svg diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-off.svg b/crates/ferese-theme/assets/icons/status/bluetooth-off.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/bluetooth-off.svg rename to crates/ferese-theme/assets/icons/status/bluetooth-off.svg diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-on.svg b/crates/ferese-theme/assets/icons/status/bluetooth-on.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/bluetooth-on.svg rename to crates/ferese-theme/assets/icons/status/bluetooth-on.svg diff --git a/shell/ferese-shell/assets/icons/status/brightness.svg b/crates/ferese-theme/assets/icons/status/brightness.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/brightness.svg rename to crates/ferese-theme/assets/icons/status/brightness.svg diff --git a/shell/ferese-shell/assets/icons/status/calendar.svg b/crates/ferese-theme/assets/icons/status/calendar.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/calendar.svg rename to crates/ferese-theme/assets/icons/status/calendar.svg diff --git a/shell/ferese-shell/assets/icons/status/chevron-down.svg b/crates/ferese-theme/assets/icons/status/chevron-down.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/chevron-down.svg rename to crates/ferese-theme/assets/icons/status/chevron-down.svg diff --git a/shell/ferese-shell/assets/icons/status/chevron-up.svg b/crates/ferese-theme/assets/icons/status/chevron-up.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/chevron-up.svg rename to crates/ferese-theme/assets/icons/status/chevron-up.svg diff --git a/shell/ferese-shell/assets/icons/status/close.svg b/crates/ferese-theme/assets/icons/status/close.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/close.svg rename to crates/ferese-theme/assets/icons/status/close.svg diff --git a/shell/ferese-shell/assets/icons/status/control-center.svg b/crates/ferese-theme/assets/icons/status/control-center.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/control-center.svg rename to crates/ferese-theme/assets/icons/status/control-center.svg diff --git a/shell/ferese-shell/assets/icons/status/dnd.svg b/crates/ferese-theme/assets/icons/status/dnd.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/dnd.svg rename to crates/ferese-theme/assets/icons/status/dnd.svg diff --git a/shell/ferese-shell/assets/icons/status/ethernet.svg b/crates/ferese-theme/assets/icons/status/ethernet.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/ethernet.svg rename to crates/ferese-theme/assets/icons/status/ethernet.svg diff --git a/shell/ferese-shell/assets/icons/status/lock.svg b/crates/ferese-theme/assets/icons/status/lock.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/lock.svg rename to crates/ferese-theme/assets/icons/status/lock.svg diff --git a/shell/ferese-shell/assets/icons/status/mic-mute.svg b/crates/ferese-theme/assets/icons/status/mic-mute.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/mic-mute.svg rename to crates/ferese-theme/assets/icons/status/mic-mute.svg diff --git a/shell/ferese-shell/assets/icons/status/mic.svg b/crates/ferese-theme/assets/icons/status/mic.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/mic.svg rename to crates/ferese-theme/assets/icons/status/mic.svg diff --git a/shell/ferese-shell/assets/icons/status/moon.svg b/crates/ferese-theme/assets/icons/status/moon.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/moon.svg rename to crates/ferese-theme/assets/icons/status/moon.svg diff --git a/shell/ferese-shell/assets/icons/status/notifications-off.svg b/crates/ferese-theme/assets/icons/status/notifications-off.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/notifications-off.svg rename to crates/ferese-theme/assets/icons/status/notifications-off.svg diff --git a/shell/ferese-shell/assets/icons/status/notifications.svg b/crates/ferese-theme/assets/icons/status/notifications.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/notifications.svg rename to crates/ferese-theme/assets/icons/status/notifications.svg diff --git a/shell/ferese-shell/assets/icons/status/power-balanced.svg b/crates/ferese-theme/assets/icons/status/power-balanced.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/power-balanced.svg rename to crates/ferese-theme/assets/icons/status/power-balanced.svg diff --git a/shell/ferese-shell/assets/icons/status/power-off.svg b/crates/ferese-theme/assets/icons/status/power-off.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/power-off.svg rename to crates/ferese-theme/assets/icons/status/power-off.svg diff --git a/shell/ferese-shell/assets/icons/status/power-performance.svg b/crates/ferese-theme/assets/icons/status/power-performance.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/power-performance.svg rename to crates/ferese-theme/assets/icons/status/power-performance.svg diff --git a/shell/ferese-shell/assets/icons/status/power-saver.svg b/crates/ferese-theme/assets/icons/status/power-saver.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/power-saver.svg rename to crates/ferese-theme/assets/icons/status/power-saver.svg diff --git a/shell/ferese-shell/assets/icons/status/power.svg b/crates/ferese-theme/assets/icons/status/power.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/power.svg rename to crates/ferese-theme/assets/icons/status/power.svg diff --git a/shell/ferese-shell/assets/icons/status/record-cancel.svg b/crates/ferese-theme/assets/icons/status/record-cancel.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/record-cancel.svg rename to crates/ferese-theme/assets/icons/status/record-cancel.svg diff --git a/shell/ferese-shell/assets/icons/status/record-saving.svg b/crates/ferese-theme/assets/icons/status/record-saving.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/record-saving.svg rename to crates/ferese-theme/assets/icons/status/record-saving.svg diff --git a/shell/ferese-shell/assets/icons/status/record-stop.svg b/crates/ferese-theme/assets/icons/status/record-stop.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/record-stop.svg rename to crates/ferese-theme/assets/icons/status/record-stop.svg diff --git a/shell/ferese-shell/assets/icons/status/record.svg b/crates/ferese-theme/assets/icons/status/record.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/record.svg rename to crates/ferese-theme/assets/icons/status/record.svg diff --git a/shell/ferese-shell/assets/icons/status/restart.svg b/crates/ferese-theme/assets/icons/status/restart.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/restart.svg rename to crates/ferese-theme/assets/icons/status/restart.svg diff --git a/shell/ferese-shell/assets/icons/status/settings.svg b/crates/ferese-theme/assets/icons/status/settings.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/settings.svg rename to crates/ferese-theme/assets/icons/status/settings.svg diff --git a/shell/ferese-shell/assets/icons/status/suspend.svg b/crates/ferese-theme/assets/icons/status/suspend.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/suspend.svg rename to crates/ferese-theme/assets/icons/status/suspend.svg diff --git a/shell/ferese-shell/assets/icons/status/trash.svg b/crates/ferese-theme/assets/icons/status/trash.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/trash.svg rename to crates/ferese-theme/assets/icons/status/trash.svg diff --git a/shell/ferese-shell/assets/icons/status/volume-high.svg b/crates/ferese-theme/assets/icons/status/volume-high.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/volume-high.svg rename to crates/ferese-theme/assets/icons/status/volume-high.svg diff --git a/shell/ferese-shell/assets/icons/status/volume-low.svg b/crates/ferese-theme/assets/icons/status/volume-low.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/volume-low.svg rename to crates/ferese-theme/assets/icons/status/volume-low.svg diff --git a/shell/ferese-shell/assets/icons/status/volume-medium.svg b/crates/ferese-theme/assets/icons/status/volume-medium.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/volume-medium.svg rename to crates/ferese-theme/assets/icons/status/volume-medium.svg diff --git a/shell/ferese-shell/assets/icons/status/volume-mute.svg b/crates/ferese-theme/assets/icons/status/volume-mute.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/volume-mute.svg rename to crates/ferese-theme/assets/icons/status/volume-mute.svg diff --git a/shell/ferese-shell/assets/icons/status/wifi-full.svg b/crates/ferese-theme/assets/icons/status/wifi-full.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/wifi-full.svg rename to crates/ferese-theme/assets/icons/status/wifi-full.svg diff --git a/shell/ferese-shell/assets/icons/status/wifi-low.svg b/crates/ferese-theme/assets/icons/status/wifi-low.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/wifi-low.svg rename to crates/ferese-theme/assets/icons/status/wifi-low.svg diff --git a/shell/ferese-shell/assets/icons/status/wifi-medium.svg b/crates/ferese-theme/assets/icons/status/wifi-medium.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/wifi-medium.svg rename to crates/ferese-theme/assets/icons/status/wifi-medium.svg diff --git a/shell/ferese-shell/assets/icons/status/wifi-off.svg b/crates/ferese-theme/assets/icons/status/wifi-off.svg similarity index 100% rename from shell/ferese-shell/assets/icons/status/wifi-off.svg rename to crates/ferese-theme/assets/icons/status/wifi-off.svg diff --git a/shell/ferese-shell/assets/icons/volume.svg b/crates/ferese-theme/assets/icons/volume.svg similarity index 100% rename from shell/ferese-shell/assets/icons/volume.svg rename to crates/ferese-theme/assets/icons/volume.svg diff --git a/shell/ferese-shell/assets/icons/wifi.svg b/crates/ferese-theme/assets/icons/wifi.svg similarity index 100% rename from shell/ferese-shell/assets/icons/wifi.svg rename to crates/ferese-theme/assets/icons/wifi.svg diff --git a/crates/ferese-theme/src/button.rs b/crates/ferese-theme/src/button.rs new file mode 100644 index 0000000..96a6d1a --- /dev/null +++ b/crates/ferese-theme/src/button.rs @@ -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)), + } +} diff --git a/crates/ferese-theme/src/calendar.rs b/crates/ferese-theme/src/calendar.rs new file mode 100644 index 0000000..2dd4be2 --- /dev/null +++ b/crates/ferese-theme/src/calendar.rs @@ -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)); + } +} diff --git a/crates/ferese-theme/src/contrast.rs b/crates/ferese-theme/src/contrast.rs new file mode 100644 index 0000000..21100db --- /dev/null +++ b/crates/ferese-theme/src/contrast.rs @@ -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); + } + } + } + } +} diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs new file mode 100644 index 0000000..bcf7532 --- /dev/null +++ b/crates/ferese-theme/src/controls.rs @@ -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.)), + } +} diff --git a/crates/ferese-theme/src/icons.rs b/crates/ferese-theme/src/icons.rs new file mode 100644 index 0000000..55f06e5 --- /dev/null +++ b/crates/ferese-theme/src/icons.rs @@ -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 { + 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##" + + "##, + ); + 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 + })); + } + } +} diff --git a/crates/ferese-theme/src/lib.rs b/crates/ferese-theme/src/lib.rs index ab4af02..e027a57 100644 --- a/crates/ferese-theme/src/lib.rs +++ b/crates/ferese-theme/src/lib.rs @@ -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}; diff --git a/crates/ferese-theme/src/material.rs b/crates/ferese-theme/src/material.rs new file mode 100644 index 0000000..32a3759 --- /dev/null +++ b/crates/ferese-theme/src/material.rs @@ -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, +} + +#[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 { + 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::(&connection).map_err(|error| error.to_string())?; + let qh = queue.handle(); + let manager = globals + .bind::(&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 for MaterialState { + fn event( + _state: &mut Self, + _registry: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch 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, + ) { + if matches!( + event, + ferese_protocols::material::v1::client::ferese_surface_material_v1::Event::Ready + ) { + state.ready = true; + } + } +} + +delegate_noop!(MaterialState: ignore FereseMaterialManagerV1); diff --git a/crates/ferese-theme/src/menus.rs b/crates/ferese-theme/src/menus.rs new file mode 100644 index 0000000..8cda52a --- /dev/null +++ b/crates/ferese-theme/src/menus.rs @@ -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> + '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> + '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> + '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> + '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, + } +} diff --git a/crates/ferese-theme/src/palette.rs b/crates/ferese-theme/src/palette.rs new file mode 100644 index 0000000..4fa6766 --- /dev/null +++ b/crates/ferese-theme/src/palette.rs @@ -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 { + 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); + } + } +} diff --git a/crates/ferese-theme/src/presets.rs b/crates/ferese-theme/src/presets.rs new file mode 100644 index 0000000..eae3f5f --- /dev/null +++ b/crates/ferese-theme/src/presets.rs @@ -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", + }, +]; diff --git a/crates/ferese-theme/src/typography.rs b/crates/ferese-theme/src/typography.rs new file mode 100644 index 0000000..51ad94c --- /dev/null +++ b/crates/ferese-theme/src/typography.rs @@ -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>> = 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> + 'a, + font: cosmic::font::Font, +) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> { + cosmic::widget::text(content).font(font) +} diff --git a/docs/configuration.md b/docs/configuration.md index ac6fd53..a7fa5ae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 | diff --git a/docs/screen-sharing.md b/docs/screen-sharing.md index b91ecdc..1c41d55 100644 --- a/docs/screen-sharing.md +++ b/docs/screen-sharing.md @@ -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 diff --git a/portal/src/picker.rs b/portal/src/picker.rs index 85190b9..b168ab6 100644 --- a/portal/src/picker.rs +++ b/portal/src/picker.rs @@ -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), Select(usize), Share, Cancel, @@ -24,10 +27,12 @@ enum Message { struct Picker { core: Core, + material: Option, prompt: Prompt, selected: Vec, background: cosmic::iced::Color, foreground: cosmic::iced::Color, + font: cosmic::font::Font, } pub fn run() -> Result<(), Box> { @@ -42,15 +47,17 @@ pub fn run() -> Result<(), Box> { } 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::( 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> { 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) { 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 { + 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 { 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 { 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> + '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.); - } -} diff --git a/protocols/ferese-material-v1.xml b/protocols/ferese-material-v1.xml new file mode 100644 index 0000000..6545c17 --- /dev/null +++ b/protocols/ferese-material-v1.xml @@ -0,0 +1,29 @@ + + + + + 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. + + + + + + + + + + + + + The material changes only rendering, never focus, input, geometry or stacking. + Destroying this object restores the surface's ordinary rendering. + + + + + + + diff --git a/shell/ferese-lock/src/appearance.rs b/shell/ferese-lock/src/appearance.rs index 8d954f1..8d0df76 100644 --- a/shell/ferese-lock/src/appearance.rs +++ b/shell/ferese-lock/src/appearance.rs @@ -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, } + 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 { - 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 { diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs index f585a3c..2ec00c4 100644 --- a/shell/ferese-lock/src/main.rs +++ b/shell/ferese-lock/src/main.rs @@ -343,7 +343,7 @@ impl Locker { label: impl Into> + '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::*; diff --git a/shell/ferese-polkit-agent/src/prompt.rs b/shell/ferese-polkit-agent/src/prompt.rs index dffa11d..0f0ca4f 100644 --- a/shell/ferese-polkit-agent/src/prompt.rs +++ b/shell/ferese-polkit-agent/src/prompt.rs @@ -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), Tick, Input(String), Submit, @@ -130,6 +84,7 @@ impl std::fmt::Debug for Message { struct Prompt { core: Core, + material: Option, appearance: Appearance, incoming: Arc>>, 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> { 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 { 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 { - 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 { 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) diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 84146e7..47fbca9 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -128,7 +128,7 @@ impl cosmic::Application for App { let error = initial.as_ref().err().cloned(); let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap()); let font = visuals::configured_font(¤t); - let native_palette = visuals::Palette::from(¤t); + let native_palette = visuals::Palette::from_document(Some(¤t.doc)); let mut app = Self { note_editors: HashMap::new(), core, @@ -517,7 +517,7 @@ impl cosmic::Application for App { } fn view(&self) -> Element<'_, Message> { - let palette = visuals::Palette::from(&self.draft); + let palette = visuals::Palette::from_document(Some(&self.draft.doc)); let mut sidebar = column([]) .spacing(3) .push( @@ -1013,7 +1013,7 @@ impl cosmic::Application for App { impl App { fn update_theme(&mut self) -> Task { - 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> + '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.)); diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs index ae15eda..3b96fa2 100644 --- a/shell/ferese-settings/src/visuals.rs +++ b/shell/ferese-settings/src/visuals.rs @@ -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::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##" - -"##, - 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##" - -"##, - 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 { 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, diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index ea4d766..38278af 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -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; diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index a1e5460..0c05dba 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -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::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::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> + '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 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::(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 { - 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> { 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(); diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index 8a6b5d6..551cd7a 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -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 { diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index 76b316f..8823fa8 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -82,24 +82,17 @@ fn card_button<'a>( round: bool, accessible_name: String, ) -> Element<'a, cosmic::Action> { - 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, diff --git a/shell/ferese-shell/src/status_ui.rs b/shell/ferese-shell/src/status_ui.rs index 45c2f9a..88441e3 100644 --- a/shell/ferese-shell/src/status_ui.rs +++ b/shell/ferese-shell/src/status_ui.rs @@ -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> { - 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> = 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> { - 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> { 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> { - 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> { - 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> { - 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,