From d75c93db85f4abcdd69a7c321fbbe708ceec409b Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 4 Oct 2026 07:39:54 +0100 Subject: [PATCH] Match shell squircle outlines with compositor masks Use shared corner geometry for shell controls, window clipping, materials and blur masks. Preserve circular pills and controls where appropriate, track contour changes in damage, and use tagged ferese-shape and libcosmic dependencies. --- Cargo.lock | 45 +++--- Cargo.toml | 3 +- README.md | 2 +- compositor/Cargo.toml | 1 + compositor/src/presentation.rs | 7 +- compositor/src/render/corner_tests.rs | 102 ++++++++++++-- compositor/src/render/decorations.rs | 2 +- compositor/src/render/materials.rs | 2 +- compositor/src/render/mod.rs | 20 +-- .../src/render/shaders/material_shader.frag | 3 + .../render/shaders/rounded_border_shader.frag | 2 +- .../src/render/shaders/window_corners.glsl | 20 ++- crates/ferese-theme/Cargo.toml | 2 +- crates/ferese-theme/src/button.rs | 3 + crates/ferese-theme/src/calendar.rs | 2 + crates/ferese-theme/src/controls.rs | 22 +++ crates/ferese-theme/src/gallery.rs | 5 + crates/ferese-theme/src/geometry.rs | 2 +- crates/ferese-theme/src/menus.rs | 3 + crates/ferese-theme/src/palette.rs | 8 +- docs/configuration.md | 7 +- shell/ferese-lock/src/main.rs | 2 + shell/ferese-settings/src/main.rs | 114 +++++++++++++++ shell/ferese-shell/Cargo.toml | 2 +- shell/ferese-shell/src/bar.rs | 5 + shell/ferese-shell/src/desktop_widgets.rs | 3 + shell/ferese-shell/src/notification_ui.rs | 133 ++++++++++++++++-- shell/ferese-shell/src/status_ui/bar.rs | 2 + shell/ferese-shell/src/status_ui/controls.rs | 3 + shell/ferese-shell/src/status_ui/mod.rs | 3 + shell/ferese-shell/src/system_modal.rs | 3 + 31 files changed, 453 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4f8826..6ee9349 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -661,7 +661,7 @@ dependencies = [ [[package]] name = "build_helpers" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "cfg_aliases", ] @@ -902,7 +902,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.1.14", ] [[package]] @@ -1034,7 +1034,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "1.0.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1055,7 +1055,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "1.0.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "quote", "syn 2.0.119", @@ -1124,7 +1124,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "1.0.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "almost", "configparser", @@ -1655,6 +1655,7 @@ dependencies = [ "ferese-ipc", "ferese-layout", "ferese-protocols", + "ferese-shape", "fontdb", "futures-lite", "image", @@ -1782,6 +1783,11 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ferese-shape" +version = "0.1.0" +source = "git+https://github.com/ferese-wm/ferese-shape?tag=v0.1.0#63d7b6dfd520dd882fc493d3272e26029ae0c9b8" + [[package]] name = "ferese-shell" version = "0.1.0" @@ -2617,7 +2623,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "build_helpers", "dnd", @@ -2639,7 +2645,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "accesskit", "accesskit_winit", @@ -2648,13 +2654,14 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "bitflags 2.13.2", "build_helpers", "bytes", "cosmic-client-toolkit", "dnd", + "ferese-shape", "glam", "iced_accessibility", "lilt", @@ -2675,7 +2682,7 @@ dependencies = [ [[package]] name = "iced_debug" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "iced_core", "iced_futures", @@ -2685,7 +2692,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "futures", "iced_core", @@ -2699,7 +2706,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "bitflags 2.13.2", "bytemuck", @@ -2720,7 +2727,7 @@ dependencies = [ [[package]] name = "iced_program" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "iced_graphics", "iced_runtime", @@ -2729,7 +2736,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -2741,7 +2748,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "build_helpers", "bytes", @@ -2758,7 +2765,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "bytemuck", "cosmic-text", @@ -2775,7 +2782,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "as-raw-xcb-connection", "bitflags 2.13.2", @@ -2807,7 +2814,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.2" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "build_helpers", "cosmic-client-toolkit", @@ -2827,7 +2834,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "build_helpers", "cosmic-client-toolkit", @@ -3358,7 +3365,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libcosmic" version = "1.0.0" -source = "git+https://github.com/pop-os/libcosmic?rev=03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0#03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" +source = "git+https://github.com/ferese-wm/libcosmic?tag=ferese-v0.1.0#9e7b137321cc197eca5034470031ab4896781048" dependencies = [ "apply", "ashpd 0.12.3", diff --git a/Cargo.toml b/Cargo.toml index dd66ff5..7501eaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,11 +26,12 @@ license = "MIT" rust-version = "1.95" [workspace.dependencies] +ferese-shape = { git = "https://github.com/ferese-wm/ferese-shape", tag = "v0.1.0" } bitflags = "2.9.4" calloop = { version = "0.14.4", features = ["signals"] } jiff = "0.2.37" libc = "0.2.176" -libcosmic = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0", default-features = false, features = ["multi-window", "tokio", "wayland"] } +libcosmic = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.0", default-features = false, features = ["multi-window", "tokio", "wayland"] } smithay = { version = "0.7.0", default-features = false, features = [ "backend_drm", "backend_gbm", diff --git a/README.md b/README.md index db48978..caaff40 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Ferese combines a window manager and desktop shell with one configuration. - [Continuum motion](docs/configuration.md#animations): interruptible springs, gesture momentum and motion settings shared by the compositor and shell - Multiple monitors with fractional scaling and automatic refresh-rate switching on low battery - Themes, wallpapers and blur that update live, with Light/Dark/Auto modes and custom KDL themes -- Squircle window corners with matching borders and shadows +- Squircle window and shell corners with matching borders, shadows and blur masks - Desktop widgets, including a clock and sticky notes - Screenshots with annotation, screen sharing and built-in recording - A built-in lock screen and authentication dialogs diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 57913ea..1de664e 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +ferese-shape.workspace = true ferese-config = { path = "../crates/ferese-config" } ab_glyph = "0.2.32" fontdb = "0.23.0" diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs index a2c23d8..c70d2a9 100644 --- a/compositor/src/presentation.rs +++ b/compositor/src/presentation.rs @@ -146,12 +146,7 @@ pub(crate) fn physical_rect( ) } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum CornerShape { - #[default] - Circular, - Continuous, -} +pub(crate) use ferese_shape::Shape as CornerShape; /// Conservative extent of the corner shoulders, in physical pixels. pub(crate) fn corner_extent(radius: f32, size: smithay::utils::Size, shape: CornerShape) -> f32 { diff --git a/compositor/src/render/corner_tests.rs b/compositor/src/render/corner_tests.rs index e5145a8..4da6adb 100644 --- a/compositor/src/render/corner_tests.rs +++ b/compositor/src/render/corner_tests.rs @@ -153,28 +153,50 @@ fn coverage(distance: f64) -> f64 { } #[test] -fn shell_roles_and_shader_sources_keep_circular_corners() { - use crate::effects::SemanticRole::*; +fn shared_geometry_matches_the_existing_window_profile() { + let bounds = [1.25, 2.5, 61.5, 59.0]; - for role in [Panel, PanelElevated, Popover, Menu, Notification, Hud, Modal] { - assert_eq!(corner_shape_for_role(Some(role)), CornerShape::Circular); + for shape in [CornerShape::Circular, CornerShape::Continuous] { + for radius in [0.0, 0.375, 8.25, 12.375, 24.0, 29.49, 29.5] { + let outline = ferese_shape::Outline::new(bounds, [radius; 4], shape).unwrap(); + + for y in 0..64 { + for x in 0..64 { + let point = [x as f64 + 0.5, y as f64 + 0.5]; + let expected = reference_distance(point, bounds, radius, shape); + let actual = outline.signed_distance(point); + assert!( + (actual - expected).abs() < 0.01, + "{shape:?} radius={radius} point={point:?}" + ); + assert!((coverage(actual) - coverage(expected)).abs() <= 2.0 / 255.0); + } + } + } } +} - assert_eq!(corner_shape_for_role(None), CornerShape::Continuous); - +#[test] +fn shell_masks_match_the_shared_squircle_profile() { for source in [BLUR_SHADER, MATERIAL_SHADER, ROUNDED_TEXTURE_SHADER] { assert_eq!( corner_shader(source), - source.replace("//_CORNERS_", include_str!("shaders/corners.glsl")) + corner_shader_for(source, CornerShape::Continuous) ); - assert!(!corner_shader(source).contains("CORNER_EXTENT")); + assert!(corner_shader(source).contains("CORNER_EXTENT")); + assert!(!corner_shader_for(source, CornerShape::Circular).contains("CORNER_EXTENT")); } } fn renderer() -> GlesRenderer { use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay}; - let device = EGLDevice::enumerate().unwrap().last().expect("an EGL device"); + let devices = EGLDevice::enumerate().unwrap().collect::>(); + let software = std::env::var_os("FERESE_TEST_EGL_SOFTWARE").is_some(); + let device = devices + .into_iter() + .find(|device| device.is_software() == software) + .expect("the requested EGL device"); let display = unsafe { EGLDisplay::new(device).unwrap() }; unsafe { GlesRenderer::new(EGLContext::new(&display).unwrap()).unwrap() } } @@ -334,12 +356,12 @@ fn apple_window_fill_and_inset_border_match_path_reference_at_fractional_scales( #[test] #[ignore = "requires an EGL rendering device"] -fn window_programs_do_not_change_cached_shell_materials_and_blur() { +fn circular_and_squircle_programs_keep_independent_caches() { let mut renderer = renderer(); let mut resources = RenderResources::default(); let context = renderer.context_id().erased(); - let circular = rounded_clip_program(&mut resources, &mut renderer).unwrap(); - material_program(&mut resources, &mut renderer).unwrap(); + let circular = corner_program(&mut resources, &mut renderer, CornerShape::Circular).unwrap(); + material_program_for_corners(&mut resources, &mut renderer, CornerShape::Circular).unwrap(); blur_program(&mut resources, &mut renderer).unwrap(); let uniforms = [ Uniform::new("clip_rect", [0.0f32, 0.0, 64.0, 64.0]), @@ -349,7 +371,7 @@ fn window_programs_do_not_change_cached_shell_materials_and_blur() { let before = rasterize(&mut renderer, &circular.solid, &uniforms); corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); material_program_for_corners(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); - let circular = rounded_clip_program(&mut resources, &mut renderer).unwrap(); + let circular = corner_program(&mut resources, &mut renderer, CornerShape::Circular).unwrap(); let after = rasterize(&mut renderer, &circular.solid, &uniforms); assert_eq!(before, after); let cache = &resources.contexts[&context]; @@ -550,3 +572,57 @@ fn measured_uikit_profile_has_flat_shoulders_but_small_internal_join_discontinui assert!((curvature_jump - 0.354_989_57).abs() < 1.0e-6); } } + +#[test] +#[ignore = "manual EGL timing; optional FERESE_CORNER_REFERENCE_SHADER path"] +fn corner_heavy_shader_timing_sample() { + use smithay::backend::renderer::gles::{UniformName, UniformType}; + let mut renderer = renderer(); + let reference = std::env::var_os("FERESE_CORNER_REFERENCE_SHADER") + .map(|path| std::fs::read_to_string(path).expect("reference GLSL")); + let current = include_str!("shaders/window_corners.glsl"); + let size = (1024, 768).into(); + let rect = Rectangle::::from_size(size); + let mut texture: GlesTexture = renderer.create_buffer(Fourcc::Abgr8888, (1024, 768).into()).unwrap(); + let body = "\nvoid main() { float d = rounded_rect_distance(mod(gl_FragCoord.xy, vec2(64.0)), vec4(0.0, 0.0, 64.0, 64.0), radius); gl_FragColor = vec4(edge_coverage(d)); }"; + for (name, source) in reference + .as_deref() + .map(|s| ("reference", s)) + .into_iter() + .chain([("optimized", current)]) + { + let shader = format!("precision highp float; uniform float radius;\n{source}{body}"); + let program = renderer + .compile_custom_pixel_shader(shader, &[UniformName::new("radius", UniformType::_1f)]) + .unwrap(); + for radius in [8.0f32, 16.0, 24.0] { + let mut samples = Vec::new(); + for i in 0..110 { + let start = std::time::Instant::now(); + let mut target = renderer.bind(&mut texture).unwrap(); + let mut frame = renderer.render(&mut target, size, Transform::Normal).unwrap(); + frame.clear(Color32F::TRANSPARENT, &[rect]).unwrap(); + frame + .render_pixel_shader_to( + &program, + Rectangle::from_size((1024.0, 768.0).into()), + rect, + (1024, 768).into(), + Some(&[rect]), + 1.0, + &[Uniform::new("radius", radius)], + ) + .unwrap(); + frame.finish().unwrap().wait().unwrap(); + if i >= 10 { + samples.push(start.elapsed()); + } + } + samples.sort_unstable(); + eprintln!( + "corner-heavy {name} radius={radius}: median={:?}, p95={:?}", + samples[50], samples[95] + ); + } + } +} diff --git a/compositor/src/render/decorations.rs b/compositor/src/render/decorations.rs index 832e37d..56c1341 100644 --- a/compositor/src/render/decorations.rs +++ b/compositor/src/render/decorations.rs @@ -4,7 +4,7 @@ pub(super) fn rounded_clip_program( resources: &mut RenderResources, renderer: &mut GlesRenderer, ) -> Option { - corner_program(resources, renderer, CornerShape::Circular) + corner_program(resources, renderer, CornerShape::Continuous) } pub(super) fn corner_program( diff --git a/compositor/src/render/materials.rs b/compositor/src/render/materials.rs index 7df6a78..02fea8e 100644 --- a/compositor/src/render/materials.rs +++ b/compositor/src/render/materials.rs @@ -428,7 +428,7 @@ pub(super) fn material_program( resources: &mut RenderResources, renderer: &mut GlesRenderer, ) -> Option { - material_program_for_corners(resources, renderer, CornerShape::Circular) + material_program_for_corners(resources, renderer, CornerShape::Continuous) } pub(super) fn material_program_for_corners( diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index b882e6c..d7d46bb 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -119,7 +119,7 @@ const MATERIAL_SHADER: &str = include_str!("shaders/material_shader.frag"); const BLUR_SHADER: &str = include_str!("shaders/blur_shader.frag"); fn corner_shader(source: &str) -> String { - corner_shader_for(source, CornerShape::Circular) + corner_shader_for(source, CornerShape::Continuous) } fn corner_shader_for(source: &str, shape: CornerShape) -> String { @@ -137,21 +137,9 @@ fn corner_shader_for(source: &str, shape: CornerShape) -> String { } } -pub(crate) fn window_corner_shape(window: &smithay::desktop::Window) -> CornerShape { - // Semantic shell surfaces keep their client-matching circular outlines. - corner_shape_for_role( - window - .toplevel() - .and_then(|toplevel| crate::effects::surface_role(toplevel.wl_surface()).map(|(role, _)| role)), - ) -} - -fn corner_shape_for_role(role: Option) -> CornerShape { - if role.is_some() { - CornerShape::Circular - } else { - CornerShape::Continuous - } +pub(crate) fn window_corner_shape(_window: &smithay::desktop::Window) -> CornerShape { + // Shell surfaces and application windows use the same shared squircle profile. + CornerShape::Continuous } #[derive(Clone, Debug)] diff --git a/compositor/src/render/shaders/material_shader.frag b/compositor/src/render/shaders/material_shader.frag index 4e5603e..b26d989 100644 --- a/compositor/src/render/shaders/material_shader.frag +++ b/compositor/src/render/shaders/material_shader.frag @@ -15,6 +15,9 @@ void main() { float sdf = rounded_rect_distance(gl_FragCoord.xy, visible_rect, material_radius); float coverage = edge_coverage(sdf); if (paint_mode > 0.5) { + // The shadow pass is multiplied by (1 - coverage). Fully covered + // pixels need neither a second contour solve nor a Gaussian sample. + if (coverage >= 1.0) { gl_FragColor = vec4(0.0); return; } float distance = max(rounded_rect_distance(gl_FragCoord.xy, shadow_rect, material_radius), 0.0); float sigma = max(shadow_values.x * 0.5, 0.5); float opacity = exp(-0.5 * distance * distance / (sigma * sigma)) diff --git a/compositor/src/render/shaders/rounded_border_shader.frag b/compositor/src/render/shaders/rounded_border_shader.frag index a359c23..902898f 100644 --- a/compositor/src/render/shaders/rounded_border_shader.frag +++ b/compositor/src/render/shaders/rounded_border_shader.frag @@ -26,7 +26,7 @@ void main() { #ifdef CONTINUOUS_WINDOW_CORNERS // Inset the outer distance field along its normals, rather than shrinking - // the quartic's bounding box/radius and changing border thickness. + // the profile's bounding box/radius and changing border thickness. float inner_coverage = edge_coverage(signed_distance + border_width); #else vec2 inner_size = max(clip_rect.zw - vec2(2.0 * border_width), vec2(0.0)); diff --git a/compositor/src/render/shaders/window_corners.glsl b/compositor/src/render/shaders/window_corners.glsl index 8843dba..69e22ad 100644 --- a/compositor/src/render/shaders/window_corners.glsl +++ b/compositor/src/render/shaders/window_corners.glsl @@ -44,7 +44,11 @@ float corner_nearest(vec2 query, float t, vec2 a, vec2 b, vec2 c, vec2 d) { vec2 error = point - query; float speed_squared = dot(tangent, tangent); float denominator = max(speed_squared + dot(error, acceleration), speed_squared * 0.25); - t = clamp(t - clamp(dot(error, tangent) / denominator, -0.25, 0.25), 0.0, 1.0); + float next = clamp(t - clamp(dot(error, tangent) / denominator, -0.25, 0.25), 0.0, 1.0); + if (next == t) { + break; + } + t = next; } return t; @@ -71,13 +75,23 @@ float rounded_rect_distance(vec2 point, vec4 rect, float radius) { return -min(inset.x, inset.y); } - vec2 query = inset / radius; + // Reflect onto the first half of the symmetric corner. The opposite + // shoulder cannot be nearer than its reflection, so omit segment 2. + vec2 query = vec2(min(inset.x, inset.y), max(inset.x, inset.y)) / radius; float best_squared = 1.0e20; float side = 1.0; - for (int segment = 0; segment < 3; segment++) { + for (int segment = 0; segment < 2; segment++) { vec2 a, b, c, d; corner_controls(segment, blend, a, b, c, d); + // The cubic stays inside its control hull. Reject only when even + // that hull's box cannot improve the closest point found so far. + vec2 low = min(min(a, b), min(c, d)); + vec2 high = max(max(a, b), max(c, d)); + vec2 delta = max(max(low - query, query - high), 0.0); + if (dot(delta, delta) > best_squared) { + continue; + } for (int seed = 0; seed < 2; seed++) { float t = corner_nearest(query, float(seed), a, b, c, d); diff --git a/crates/ferese-theme/Cargo.toml b/crates/ferese-theme/Cargo.toml index ae71748..73d3be3 100644 --- a/crates/ferese-theme/Cargo.toml +++ b/crates/ferese-theme/Cargo.toml @@ -10,7 +10,7 @@ jiff.workspace = true ferese-theme-model = { path = "../ferese-theme-model" } libcosmic = { workspace = true, features = ["a11y"] } -iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" } +iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.0" } [dev-dependencies] ferese-config = { path = "../ferese-config" } diff --git a/crates/ferese-theme/src/button.rs b/crates/ferese-theme/src/button.rs index 2d3a6ab..dd48234 100644 --- a/crates/ferese-theme/src/button.rs +++ b/crates/ferese-theme/src/button.rs @@ -1,3 +1,4 @@ +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::{Background, Color}; use cosmic::theme; use cosmic::widget::button; @@ -27,6 +28,8 @@ pub(crate) fn accent_button_style(theme: &cosmic::Theme, state: State, focused: let background = composite(fill, native.primary(false).base.into()); let foreground = foreground(background, component.on.into()); button::Style { + shape: Some(BorderShape::Continuous), + outline: None, background: Some(Background::Color(background)), text_color: Some(foreground), icon_color: Some(foreground), diff --git a/crates/ferese-theme/src/calendar.rs b/crates/ferese-theme/src/calendar.rs index 8f839e4..7196db6 100644 --- a/crates/ferese-theme/src/calendar.rs +++ b/crates/ferese-theme/src/calendar.rs @@ -1,4 +1,5 @@ //! Date/time labels and a calendar grid; callers own navigation and popup behavior. +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::{Alignment, Background, Border, Length}; use cosmic::widget::{column, container, row}; use cosmic::{Element, theme}; @@ -124,6 +125,7 @@ pub fn grid<'a, M: Clone + 'a>( .class(theme::Container::custom(move |_| container::Style { background: is_today.then_some(Background::Color(palette.accent.scale_alpha(0.2 * opacity))), border: Border { + shape: BorderShape::Continuous, radius: palette.radius.min(12.).into(), ..Default::default() }, diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index a08e581..77fb528 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -1,3 +1,4 @@ +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::{Background, Border, Color, Vector}; use cosmic::widget::{button, container}; use cosmic::{theme, widget}; @@ -66,6 +67,8 @@ pub fn text_button<'a, M: Clone + 'a>( pub fn switch<'a, M: Clone + 'a>(enabled: bool, palette: Palette) -> button::Button<'a, M> { let paint = move |focused| button::Style { + shape: Some(BorderShape::Continuous), + outline: None, background: None, text_color: Some(palette.text), icon_color: Some(palette.text), @@ -100,6 +103,8 @@ pub fn navigation_style(p: Palette, selected: bool) -> theme::Button { pub fn settings_input(p: Palette) -> theme::TextInput { let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { + shape: Some(BorderShape::Continuous), + outline: None, background: mix(p.card, p.text, if hovered { 0.06 } else { 0.035 }).into(), border_radius: p.radius.min(7.).into(), border_width: 1., @@ -133,9 +138,11 @@ pub fn select<'a, M: 'a>( widget::container(content).class(theme::Container::custom(move |_| container::Style { background: Some(mix(p.card, p.text, 0.035).into()), border: Border { + shape: BorderShape::Continuous, width: 1., color: mix(p.card, p.text, 0.18), radius: p.radius.min(7.).into(), + ..Default::default() }, ..Default::default() })) @@ -170,6 +177,8 @@ fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32, foc }; button::Style { + shape: Some(BorderShape::Continuous), + outline: None, background: Some(Background::Color(background)), text_color: Some(on), icon_color: Some(on), @@ -208,6 +217,8 @@ pub fn authentication_button(palette: Palette) -> theme::Button { (crate::composite(palette.card, palette.background), palette.muted) }; button::Style { + shape: Some(BorderShape::Continuous), + outline: None, background: Some(Background::Color(fill)), text_color: Some(foreground), icon_color: Some(foreground), @@ -231,6 +242,8 @@ pub fn authentication_input(palette: Palette) -> theme::TextInput { let accent = palette.accent; let radius = palette.radius.min(10.); let appearance = move |focused: bool| widget::text_input::Appearance { + shape: Some(BorderShape::Continuous), + outline: None, background: Color::from_rgba(text.r, text.g, text.b, 0.045).into(), border_radius: radius.into(), border_width: 1., @@ -258,6 +271,8 @@ pub fn authentication_input(palette: Palette) -> theme::TextInput { 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 { + shape: Some(BorderShape::Continuous), + outline: None, background: cosmic::iced::Color::from_rgba( (surface.r + 1.) * 0.5, (surface.g + 1.) * 0.5, @@ -299,6 +314,8 @@ pub fn shell_button( pressed: bool, ) -> button::Style { button::Style { + shape: Some(BorderShape::Continuous), + outline: None, text_color: Some(foreground), icon_color: Some(foreground), border_radius: radius.into(), @@ -318,6 +335,7 @@ pub fn surface_appearance(background: Color, radius: f32) -> container::Style { container::Style { background: Some(Background::Color(background)), border: Border { + shape: BorderShape::Continuous, radius: radius.into(), ..Default::default() }, @@ -327,6 +345,8 @@ pub fn surface_appearance(background: Color, radius: f32) -> container::Style { pub fn notification_button(foreground: Color, hover: Color, radius: f32, filled: bool) -> theme::Button { let style = move |active: bool| button::Style { + shape: Some(BorderShape::Continuous), + outline: None, text_color: Some(foreground), icon_color: Some(foreground), background: (active || filled).then_some(Background::Color(hover)), @@ -343,6 +363,8 @@ pub fn notification_button(foreground: Color, hover: Color, radius: f32, filled: pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32) -> theme::Button { let paint = move |outline| button::Style { + shape: Some(BorderShape::Continuous), + outline: None, background: Some(Color { a: opacity, ..fill }.into()), text_color: Some(foreground), icon_color: Some(foreground), diff --git a/crates/ferese-theme/src/gallery.rs b/crates/ferese-theme/src/gallery.rs index f7b830e..0283cfc 100644 --- a/crates/ferese-theme/src/gallery.rs +++ b/crates/ferese-theme/src/gallery.rs @@ -1,5 +1,6 @@ use cosmic::iced::advanced::widget::{Id, Operation, Tree, tree}; use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer}; +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::{Event, Length, Rectangle, Size, keyboard}; use cosmic::{Element, Renderer, Theme}; use ferese_theme_model::Appearance; @@ -65,6 +66,8 @@ pub fn tile( fn tile_style(palette: crate::Palette, selected: bool) -> cosmic::theme::Button { let style = move |focused: bool, hovered: bool| cosmic::widget::button::Style { + shape: Some(BorderShape::Continuous), + outline: None, background: Some(if hovered { palette.sidebar } else { palette.card }.into()), border_width: if selected { 2. } else { 0. }, border_color: palette.accent, @@ -395,9 +398,11 @@ impl Widget for Radio<'_, M> { renderer::Quad { bounds: layout.bounds(), border: cosmic::iced::Border { + shape: BorderShape::Continuous, color: style.text_color, width: 2., radius: 10.into(), + ..Default::default() }, ..Default::default() }, diff --git a/crates/ferese-theme/src/geometry.rs b/crates/ferese-theme/src/geometry.rs index 629a2b2..05096b2 100644 --- a/crates/ferese-theme/src/geometry.rs +++ b/crates/ferese-theme/src/geometry.rs @@ -1,4 +1,4 @@ -/// Radius of a concentric inset, in the same units as the outer radius. +/// Radius of a circular inset. Squircle insets retain an `Outline` and its distance offset. pub fn inner_radius(outer: f32, padding: f32) -> f32 { (outer - padding).max(0.0) } diff --git a/crates/ferese-theme/src/menus.rs b/crates/ferese-theme/src/menus.rs index 2fa268e..cf7b2e5 100644 --- a/crates/ferese-theme/src/menus.rs +++ b/crates/ferese-theme/src/menus.rs @@ -1,5 +1,6 @@ //! Shared menu layouts and paint; callers own actions and animation timing. use cosmic::Element; +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::{Alignment, Color, Length}; use cosmic::widget::{button, container, row as iced_row}; @@ -115,11 +116,13 @@ pub fn slider(foreground: Color, opacity: f32) -> cosmic::theme::iced::Slider { ), width: 4., border: cosmic::iced::Border { + shape: BorderShape::Continuous, radius: 2.into(), ..Default::default() }, }, handle: Handle { + corner_shape: BorderShape::Circular, shape: HandleShape::Circle { radius: 6. }, background: foreground.into(), border_width: 0., diff --git a/crates/ferese-theme/src/palette.rs b/crates/ferese-theme/src/palette.rs index 63209ff..69533c7 100644 --- a/crates/ferese-theme/src/palette.rs +++ b/crates/ferese-theme/src/palette.rs @@ -1,4 +1,5 @@ use cosmic::iced::Color; +use cosmic::iced::border::Shape as BorderShape; #[derive(Clone, Copy, Debug, PartialEq)] pub struct Palette { @@ -68,8 +69,9 @@ impl Palette { .text_tint(rgba(self.text).color) .accent(rgba(self.accent).color) .build(); + crate::apply(&mut native, self.on_accent); - cosmic::Theme::custom(std::sync::Arc::new(native)) + cosmic::Theme::custom(std::sync::Arc::new(native)).corner_shape(BorderShape::Continuous) } pub fn application_style(self, background: Color) -> cosmic::iced::theme::Style { @@ -135,7 +137,9 @@ mod tests { resolved.accessibility.increase_contrast = high_contrast; let palette = Palette::from_resolved(&resolved); assert_eq!(palette.high_contrast, high_contrast); - assert_eq!(palette.native_theme().cosmic().is_high_contrast, high_contrast); + let native = palette.native_theme(); + assert_eq!(native.cosmic().is_high_contrast, high_contrast); + assert_eq!(native.corner_shape, BorderShape::Continuous); } } } diff --git a/docs/configuration.md b/docs/configuration.md index f1fef4d..fad2f7b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -193,7 +193,7 @@ Live windows, overview thumbnails and retained images share a window identity and presentation state. Entering overview inherits the window's visible position and velocity, including motion from a workspace slide. Resize images use the same content mapping in overview and remain visible if the window closes during a resize. -Content, clipping and borders use the same rounded rectangle at the output's scale. +Content, clipping and borders use the same squircle outline at the output's scale. ### Spring tuning @@ -837,7 +837,10 @@ Text input is available by default; Ctrl+Alt+Escape releases an active shortcut inhibitor. Use `feresectl --help` for runtime control commands. Shell rounding is controlled in Settings → Appearance → Shell corner radius. -Small controls cap the radius to fit their size. Window rounding remains under +The shell and compositor use the same squircle profile for fills, borders, shadows +and blur masks. Small controls cap the radius to fit their size; circles and pills +keep circular outlines. The measured profile has small tangent and curvature +discontinuities at its internal segment joins. Window rounding remains under Settings → Windows. `theme.geometry.shell-radius` takes precedence over the old `appearance.corner-radius` and then `theme.geometry.top-bar-radius` keys; when none are set, the shell uses 14 px. Legacy clock/note radius fields no longer diff --git a/shell/ferese-lock/src/main.rs b/shell/ferese-lock/src/main.rs index 9a12d8c..0d79e69 100644 --- a/shell/ferese-lock/src/main.rs +++ b/shell/ferese-lock/src/main.rs @@ -457,9 +457,11 @@ impl Locker { text_color: Some(on), icon_color: Some(on), border: iced::Border { + shape: cosmic::iced::border::Shape::Continuous, radius: (avatar_size / 2.).into(), width: 1., color: a.accent.scale_alpha(0.3), + ..Default::default() }, ..Default::default() } diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index b70b68a..0d23334 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -1130,6 +1130,120 @@ mod tests { } } + #[test] + #[ignore = "release scroll/render sample; requires both renderer backends"] + fn profile_dense_pages_on_both_renderers() { + use cosmic::iced::advanced::renderer::{Headless, Renderer as _}; + use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer, widget::Tree}; + use cosmic::iced::{Color, Event, Font, Pixels, Point, Rectangle, Size}; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let bounds = Rectangle::with_size(Size::new(1000.0, 780.0)); + let scale = 1.25; + let size = Size::new(1250, 975); + + for backend in ["wgpu", "tiny-skia"] { + let mut renderer = runtime + .block_on(::new( + Font::default(), + Pixels(14.0), + Some(backend), + )) + .expect("requested renderer"); + eprintln!("renderer={}", Headless::name(&renderer)); + + for page in [Page::Shortcuts, Page::Appearance] { + let mut app = shortcuts(500); + app.page = page; + let theme = app.native_palette.native_theme(); + let mut tree: Option = None; + let mut samples = Vec::new(); + + for frame in 0..48 { + let mut messages = Vec::new(); + let mut clipboard = clipboard::Null; + let mut shell = Shell::new(&mut messages); + let cursor = mouse::Cursor::Available(Point::new(700.0, 450.0)); + let started = std::time::Instant::now(); + let mut view = app.page_view(); + let tree = tree.get_or_insert_with(|| Tree::new(view.as_widget())); + tree.diff(view.as_widget_mut()); + let node = + view.as_widget_mut() + .layout(tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size())); + view.as_widget_mut().update( + tree, + &Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { + x: 0.0, + y: if frame < 24 { -1.0 } else { 1.0 }, + }, + }), + Layout::new(&node), + cursor, + &renderer, + &mut clipboard, + &mut shell, + &bounds, + ); + let layout_elapsed = started.elapsed(); + renderer.reset(bounds); + let draw_started = std::time::Instant::now(); + view.as_widget().draw( + tree, + &mut renderer, + &theme, + &renderer::Style { + text_color: Color::WHITE, + icon_color: Color::WHITE, + scale_factor: scale as f64, + }, + Layout::new(&node), + cursor, + &bounds, + ); + let draw_elapsed = draw_started.elapsed(); + drop(view); + let render_started = std::time::Instant::now(); + let pixels = Headless::screenshot(&mut renderer, size, scale, theme.cosmic().bg_color().into()); + std::hint::black_box(pixels); + let render_elapsed = render_started.elapsed(); + + for message in messages { + let _ = app.update(message); + } + + if frame >= 8 { + samples.push(( + layout_elapsed.as_micros(), + draw_elapsed.as_micros(), + render_elapsed.as_micros(), + )); + } + } + + for (stage, index) in [("layout", 0), ("draw_queue", 1), ("render_and_readback", 2)] { + let mut times = samples + .iter() + .map(|sample| match index { + 0 => sample.0, + 1 => sample.1, + _ => sample.2, + }) + .collect::>(); + times.sort_unstable(); + eprintln!( + "backend={backend} page={page:?} stage={stage} median_us={} p95_us={}", + times[times.len() / 2], + times[times.len() * 95 / 100] + ); + } + } + } + } + #[test] fn disabling_auto_keeps_the_effective_appearance() { for appearance in [ diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 8dbf911..1cb40e9 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -17,7 +17,7 @@ libc.workspace = true # Keep GPU rendering available as a fallback/override. The shell defaults to # tiny-skia to avoid a second GPU initialization during desktop startup. libcosmic = { workspace = true, features = ["wgpu", "a11y"] } -iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" } +iced_accessibility = { git = "https://github.com/ferese-wm/libcosmic", tag = "ferese-v0.1.0" } serde.workspace = true serde_json.workspace = true tempfile.workspace = true diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index db2bf90..86462d9 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -2,6 +2,7 @@ use super::{ Background, BarMetrics, Border, Color, Element, FereseShell, Length, Message, ShellSnapshot, ShellTheme, alignment, bar_icon, button, color, color_with_opacity, container, control, motion, row, status_ui, text, theme, window, }; +use cosmic::iced::border::Shape as BorderShape; impl FereseShell { pub(super) fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> { @@ -327,9 +328,11 @@ pub(super) fn workspace_selector_style( None }, border: Border { + shape: BorderShape::Continuous, color: color_with_opacity(shell_theme.accent, 0.55), width: if active_elsewhere { 1.0 } else { 0.0 }, radius: shell_theme.material_radius.min(14.0).into(), + ..Default::default() }, ..Default::default() } @@ -350,9 +353,11 @@ pub(super) fn bar_group_style(theme: ShellTheme) -> container::Style { container::Style { background: Some(Background::Color(color_with_opacity(theme.border, 0.25))), border: Border { + shape: BorderShape::Continuous, color: color(theme.border), width: 1.0, radius: theme.material_radius.min(16.0).into(), + ..Default::default() }, ..Default::default() } diff --git a/shell/ferese-shell/src/desktop_widgets.rs b/shell/ferese-shell/src/desktop_widgets.rs index a71e599..a3e4b01 100644 --- a/shell/ferese-shell/src/desktop_widgets.rs +++ b/shell/ferese-shell/src/desktop_widgets.rs @@ -4,6 +4,7 @@ use super::{ configured_font, container, destroy_layer_surface, format_bar_time, motion, note_store, row, set_anchor, set_margin, stacked_clock_digits, text, theme, window, }; +use cosmic::iced::border::Shape as BorderShape; impl FereseShell { pub(super) fn flush_note_changes(&mut self) -> Task { @@ -398,6 +399,7 @@ impl FereseShell { .class(theme::Container::custom(move |_| container::Style { background, border: Border { + shape: BorderShape::Continuous, radius: radius.into(), ..Default::default() }, @@ -613,6 +615,7 @@ impl FereseShell { .class(theme::Container::custom(move |_| container::Style { background, border: Border { + shape: BorderShape::Continuous, radius: radius.into(), ..Default::default() }, diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index c6fd5af..9090134 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -1,3 +1,4 @@ +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::widget::scrollable::{Direction, Scrollbar}; use cosmic::widget::{column, scrollable}; @@ -56,6 +57,23 @@ fn card_button<'a>( .into() } +fn card_surface<'a>( + content: impl Into>>, + history: bool, + style: container::Style, +) -> Element<'a, cosmic::Action> { + container(content) + .id(if history { + "ferese-history-card" + } else { + "ferese-blur-card" + }) + .width(Length::Fill) + .clip_to_border(true) + .class(theme::Container::custom(move |_| style)) + .into() +} + fn icon_control<'a>( source: &'static [u8], label: &'static str, @@ -82,6 +100,7 @@ fn icon_control<'a>( .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(background)), border: Border { + shape: BorderShape::Continuous, radius: motion::radius(12.0).into(), ..Default::default() }, @@ -287,6 +306,7 @@ impl FereseShell { .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(well)), border: Border { + shape: BorderShape::Continuous, radius: motion::radius(15.0).into(), ..Default::default() }, @@ -313,6 +333,7 @@ impl FereseShell { .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(badge_fill.scale_alpha(opacity))), border: Border { + shape: BorderShape::Continuous, radius: motion::radius(8.0).into(), ..Default::default() }, @@ -448,15 +469,10 @@ impl FereseShell { .push(actions); } } - let card = container(face) - .id(if history { - "ferese-history-card" - } else { - "ferese-blur-card" - }) - .width(Length::Fill) - .clip(true) - .class(theme::Container::custom(move |_| container::Style { + let card = card_surface( + face, + history, + container::Style { background: if history { Some(Background::Color(Color { a: 0.045 * opacity, @@ -466,14 +482,16 @@ impl FereseShell { (!compositor_material).then_some(Background::Color(base)) }, border: Border { + shape: BorderShape::Continuous, color: color_with_opacity(palette.text_muted, 0.18 * opacity), width: if history { 0.0 } else { 1.0 }, radius: palette.material_radius.into(), + ..Default::default() }, snap: true, ..Default::default() - })); - let card: Element<'_, cosmic::Action> = card.into(); + }, + ); let card: Element<'_, cosmic::Action> = if count > 1 { // With translucent materials, complete backing rectangles show // through the face and multiply its tint. Paint only the exposed @@ -488,6 +506,7 @@ impl FereseShell { ..foreground })), border: Border { + shape: BorderShape::Continuous, radius: [0.0, 0.0, motion::radius(6.0), motion::radius(6.0)].into(), ..Default::default() }, @@ -648,6 +667,7 @@ impl FereseShell { .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(base)), border: Border { + shape: BorderShape::Continuous, radius: palette.material_radius.into(), ..Default::default() }, @@ -665,6 +685,7 @@ impl FereseShell { .class(theme::Container::custom(move |_| container::Style { background: Some(Background::Color(well)), border: Border { + shape: BorderShape::Continuous, radius: motion::radius(28.0).into(), ..Default::default() }, @@ -766,9 +787,11 @@ impl FereseShell { .class(theme::Container::custom(move |_| container::Style { background: (!compositor_material).then_some(Background::Color(color(palette.surface_popover))), border: Border { + shape: BorderShape::Continuous, color: color_with_opacity(palette.text_muted, 0.18 * opacity), width: 1.0, radius: palette.material_radius.into(), + ..Default::default() }, snap: true, ..Default::default() @@ -803,3 +826,91 @@ impl FereseShell { } } } + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::advanced::renderer::{Headless, Renderer as _}; + use cosmic::iced::advanced::{Layout, layout, mouse, widget::Tree}; + use cosmic::iced::{Font, Pixels, Rectangle, Size}; + + #[test] + fn notification_action_hover_stays_inside_card_corners() { + check_action_hover("tiny-skia"); + } + + #[test] + #[ignore = "requires a GPU or software Vulkan adapter"] + fn gpu_notification_action_hover_stays_inside_card_corners() { + check_action_hover("wgpu"); + } + + fn check_action_hover(backend: &str) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let mut renderer = runtime + .block_on(::new( + Font::default(), + Pixels(14.0), + Some(backend), + )) + .unwrap(); + let theme = cosmic::Theme::dark(); + let bounds = Rectangle::with_size(Size::new(368.0, 114.0)); + for history in [false, true] { + for scale in [1.0, 1.25, 1.5] { + let action = card_button( + cosmic::widget::Space::new().height(38), + Message::InvokeNotification(1, "action".into()), + Color::WHITE, + Color::WHITE, + false, + "Action".into(), + ); + let face = column([cosmic::widget::Space::new().height(76).into(), action]); + let mut card = card_surface( + face, + history, + container::Style { + border: Border { + shape: BorderShape::Continuous, + radius: 14.0.into(), + width: if history { 0.0 } else { 1.0 }, + ..Default::default() + }, + snap: true, + ..Default::default() + }, + ); + let mut tree = Tree::new(card.as_widget()); + let node = + card.as_widget_mut() + .layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size())); + renderer.reset(bounds); + card.as_widget().draw( + &tree, + &mut renderer, + &theme, + &Default::default(), + Layout::new(&node), + mouse::Cursor::Available((80.0, 100.0).into()), + &bounds, + ); + let size = Size::new((bounds.width * scale) as u32, (bounds.height * scale) as u32); + let pixels = Headless::screenshot(&mut renderer, size, scale, Color::TRANSPARENT); + let alpha = |x: u32, y: u32| pixels[((y * size.width + x) * 4 + 3) as usize]; + assert_eq!( + alpha(0, size.height - 1), + 0, + "{backend}, scale={scale}, history={history}" + ); + assert!( + alpha(size.width / 2, size.height - 4) > 200, + "the action hover must still render" + ); + } + } + } +} diff --git a/shell/ferese-shell/src/status_ui/bar.rs b/shell/ferese-shell/src/status_ui/bar.rs index 08b3664..f26eed3 100644 --- a/shell/ferese-shell/src/status_ui/bar.rs +++ b/shell/ferese-shell/src/status_ui/bar.rs @@ -1,3 +1,4 @@ +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::{Alignment, Background, Border, Rectangle, alignment}; use cosmic::widget::{button, container, row}; use cosmic::{Element, theme}; @@ -82,6 +83,7 @@ impl FereseShell { move |_| container::Style { background: Some(Background::Color(foreground)), border: Border { + shape: BorderShape::Continuous, radius: motion::radius(2.0).into(), ..Default::default() }, diff --git a/shell/ferese-shell/src/status_ui/controls.rs b/shell/ferese-shell/src/status_ui/controls.rs index 97d1370..fdb4e3f 100644 --- a/shell/ferese-shell/src/status_ui/controls.rs +++ b/shell/ferese-shell/src/status_ui/controls.rs @@ -1,3 +1,4 @@ +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::{Alignment, Background, Border, Color, Length}; use cosmic::widget::{button, column, container, row, slider}; use cosmic::{Element, theme}; @@ -32,6 +33,7 @@ pub(super) fn status_summary<'a>( ..badge_color })), border: Border { + shape: BorderShape::Continuous, radius: motion::radius(16.0).into(), ..Default::default() }, @@ -79,6 +81,7 @@ pub(super) fn level_meter(value: u8, foreground: Color, opacity: f32) -> Element } })), border: Border { + shape: BorderShape::Continuous, radius: motion::radius(2.5).into(), ..Default::default() }, diff --git a/shell/ferese-shell/src/status_ui/mod.rs b/shell/ferese-shell/src/status_ui/mod.rs index 91711f0..8ab006d 100644 --- a/shell/ferese-shell/src/status_ui/mod.rs +++ b/shell/ferese-shell/src/status_ui/mod.rs @@ -11,6 +11,7 @@ mod system; use bar::status_icon; use controls::{control_card, menu_button, toggle_row}; +use cosmic::iced::border::Shape as BorderShape; use cosmic::widget::column; use status::{Action, Snapshot}; @@ -242,9 +243,11 @@ impl FereseShell { text_color: Some(primary), icon_color: Some(primary), border: Border { + shape: BorderShape::Continuous, color: color_with_opacity(theme.border, p), width: 1.0, radius: theme.material_radius.into(), + ..Default::default() }, snap: true, ..Default::default() diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index 228b7a3..2168fb9 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -1,3 +1,4 @@ +use cosmic::iced::border::Shape as BorderShape; use cosmic::iced::widget::Space; use cosmic::widget::column; @@ -620,9 +621,11 @@ impl FereseShell { text_color: Some(palette.text), icon_color: Some(palette.text), border: Border { + shape: BorderShape::Continuous, color: palette.muted.scale_alpha(0.16), width: 1.0, radius: theme.material_radius.into(), + ..Default::default() }, snap: true, ..Default::default()