diff --git a/README.md b/README.md index 4ec07ca..01c11b4 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,31 @@ opacity = 0.20 style = "glass" # glass, translucent, or solid ``` +Glass roles use live backdrop sampling, a dark tint, diagonal lighting, inset +edges, static overlay grain, and role-specific shadows. Configure them with: + +```toml +[appearance] +corner_radius = 14.0 +reduced_effects = false # true selects reduced_blur or a stricter quality tier + +[appearance.glass] +tint_color = "#1C202E" +blur_scale = 1.0 # clamped to 0.5–1.5; non-finite values are rejected +grain = true +quality = "full" +``` + +Quality tiers are `full`, `no_grain`, `no_saturation`, `reduced_blur` +(at most 12 logical pixels with fewer samples), `translucent`, and `solid`. +Each tier retains earlier reductions without reducing tint opacity or changing +layout. Backdrop shader or texture allocation failure falls back to translucent. +The legacy `surface.panel_elevated` role uses the panel tint with popover elevation. +Shell shadows use restrained logical-pixel presets: panel `1/5/4%`, popover +and menu `2/8/7%`, HUD `1/4/4%`, and notification/modal `3/10/9%` +(vertical offset / blur / opacity). Private effects capabilities +remain required; public background-effect requests cannot select these materials. + Fullscreen windows are borderless and shadowless. Other managed windows use GPU rounded clipping, matching rounded focus rings, and an analytic soft shadow. These effects overlay or surround the allocation without adding client padding. @@ -130,7 +155,7 @@ their client surfaces, `Super+H/J/K/L` moves the selection using the presented preview geometry, and clicking a preview activates it without forwarding the selection click to the application. -The first production shell slice provides a floating libcosmic top bar backed +The shell provides an edge-to-edge libcosmic menu bar backed by the private shell-control connection. Build and preview it nested with: ```bash @@ -139,9 +164,12 @@ target/debug/ferese --backend nested --grant-effects --grant-shell-control -- \ target/debug/ferese-shell ``` -The Ferese mark enters overview, workspace pills switch workspaces, the clock -remains geometrically centered, and focused-application/output metadata updates -from compositor snapshots. +The Ferese mark enters overview, where workspace indicators become available. +The focused app appears on the left; status controls and day/time sit on the +right. The 28px bar has square screen edges, 19px icons, a fine bottom separator, +and no drop shadow. An 8px gap separates it from windows; adjust +`top_bar_window_gap` under `[theme.geometry]` to change it. This is additional to +the workspace outer gap. Its dark startup fallback matches the material palette. Keyboard and direct-session touchpad settings are applied at startup: diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 6280ee3..b37902c 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -25,6 +25,8 @@ pub struct Config { #[serde(default)] theme: ThemeConfig, #[serde(default)] + appearance: AppearanceConfig, + #[serde(default)] commands: HashMap>, #[serde(default)] bindings: Vec, @@ -116,6 +118,9 @@ pub struct ThemeSettings { pub shadow_blur: f64, pub shadow_opacity: f64, pub material_style: MaterialStyle, + pub glass: GlassSettings, + pub material_radius: f64, + pub panel_radius: f64, } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] @@ -127,6 +132,56 @@ pub enum MaterialStyle { Solid, } +/// Ordered quality tiers; each step retains the preceding reductions. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +#[serde(rename_all = "snake_case")] +pub enum GlassQuality { + #[default] + Full, + NoGrain, + NoSaturation, + ReducedBlur, + Translucent, + Solid, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GlassSettings { + pub tint: RgbaColor, + pub blur_scale: f32, + pub grain: bool, + pub quality: GlassQuality, +} + +impl Default for GlassSettings { + fn default() -> Self { + Self { + tint: RgbaColor([28.0 / 255.0, 32.0 / 255.0, 46.0 / 255.0, 1.0]), + blur_scale: 1.0, + grain: true, + quality: GlassQuality::Full, + } + } +} + +#[derive(Debug, Default, Deserialize)] +struct AppearanceConfig { + corner_radius: Option, + #[serde(default)] + reduced_effects: bool, + #[serde(default)] + glass: GlassConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct GlassConfig { + tint_color: Option, + blur_scale: Option, + grain: Option, + #[serde(default)] + quality: GlassQuality, +} + #[derive(Debug, Default, Deserialize)] struct ThemeConfig { #[serde(default)] @@ -202,6 +257,8 @@ struct ThemeGeometryConfig { focus_ring_width: f64, #[serde(default = "default_window_radius")] window_radius: f64, + #[serde(default)] + top_bar_radius: f64, } impl Default for ThemeGeometryConfig { @@ -210,6 +267,7 @@ impl Default for ThemeGeometryConfig { border_width: default_border_width(), focus_ring_width: default_focus_ring_width(), window_radius: default_window_radius(), + top_bar_radius: 0.0, } } } @@ -803,6 +861,26 @@ impl Config { let shadow_opacity = unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?; + let mut glass = GlassSettings::default(); + if let Some(tint) = &self.appearance.glass.tint_color { + glass.tint = parse_color(tint, "appearance.glass.tint_color")?; + // Opacity belongs to the semantic role, never the color token. + glass.tint.0[3] = 1.0; + } + glass.blur_scale = finite_theme_value( + self.appearance.glass.blur_scale.unwrap_or(1.0), + "appearance.glass.blur_scale", + )? + .clamp(0.5, 1.5) as f32; + glass.grain = self.appearance.glass.grain.unwrap_or(true); + glass.quality = self.appearance.glass.quality; + if self.appearance.reduced_effects { + glass.quality = glass.quality.max(GlassQuality::ReducedBlur); + } + let material_radius = nonnegative_theme_value( + self.appearance.corner_radius.unwrap_or(14.0), + "appearance.corner_radius", + )?; Ok(ThemeSettings { border_width, focus_ring_width, @@ -818,6 +896,12 @@ impl Config { shadow_blur, shadow_opacity, material_style: self.theme.material.style, + glass, + material_radius, + panel_radius: nonnegative_theme_value( + self.theme.geometry.top_bar_radius, + "geometry.top_bar_radius", + )?, }) } @@ -1581,6 +1665,30 @@ mod tests { assert!(invalid.gap_config().is_err()); } + #[test] + fn glass_configuration_clamps_cost_and_rejects_nonfinite_values() { + for (value, expected) in [(0.1, 0.5), (1.2, 1.2), (100.0, 1.5)] { + let config = parse(&format!("[appearance.glass]\nblur_scale = {value}")); + assert_eq!(config.theme_settings().unwrap().glass.blur_scale, expected); + } + for value in ["nan", "inf", "-inf"] { + assert!( + parse(&format!("[appearance.glass]\nblur_scale = {value}")) + .theme_settings() + .is_err() + ); + } + let settings = parse("[appearance]\nreduced_effects = true\ncorner_radius = 17.0\n[appearance.glass]\ntint_color = \"#123456\"\ngrain = false") + .theme_settings().unwrap(); + assert_eq!(settings.glass.quality, GlassQuality::ReducedBlur); + assert!(!settings.glass.grain); + assert_eq!(settings.material_radius, 17.0); + assert_eq!( + settings.glass.tint, + RgbaColor([18.0 / 255.0, 52.0 / 255.0, 86.0 / 255.0, 1.0]) + ); + } + #[test] fn parses_and_validates_theme_window_tokens() { let configured = parse( @@ -1604,6 +1712,9 @@ mod tests { shadow_blur: 24.0, shadow_opacity: 0.4, material_style: MaterialStyle::Translucent, + glass: GlassSettings::default(), + material_radius: 14.0, + panel_radius: 0.0, } ); assert!(invalid_color.theme_settings().is_err()); diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs index a961f9b..8781911 100644 --- a/compositor/src/effects.rs +++ b/compositor/src/effects.rs @@ -16,7 +16,10 @@ use smithay::{ }; use crate::{ - Ferese, config::MaterialStyle, private_client::ClientCapabilities, state::ClientState, + Ferese, + config::{GlassQuality, GlassSettings, MaterialStyle}, + private_client::ClientCapabilities, + state::ClientState, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -38,47 +41,72 @@ pub(crate) struct ResolvedMaterial { pub saturation: f32, pub brightness: f32, pub noise: f32, + pub shadow: [f64; 3], } -pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> ResolvedMaterial { - let (base_opacity, blur, saturation, brightness, noise) = match role { - SemanticRole::Panel => (0.78, 24.0, 1.08, 1.02, 0.012), - SemanticRole::PanelElevated - | SemanticRole::Popover - | SemanticRole::Menu - | SemanticRole::Notification => (0.84, 28.0, 1.10, 1.03, 0.014), - SemanticRole::Hud | SemanticRole::Modal => (0.88, 30.0, 1.06, 1.04, 0.010), +pub(crate) fn resolve_material( + role: SemanticRole, + style: MaterialStyle, + settings: GlassSettings, +) -> ResolvedMaterial { + // Restrained shell shadows in logical pixels: the full-width bar needs + // less elevation than popovers, while dialogs retain a little more depth. + let (opacity, blur, saturation, shadow) = match role { + SemanticRole::Panel => (0.55, 24.0, 1.35, [1.0, 5.0, 0.04]), + SemanticRole::PanelElevated => (0.55, 24.0, 1.35, [2.0, 8.0, 0.07]), + SemanticRole::Popover => (0.55, 22.0, 1.35, [2.0, 8.0, 0.07]), + SemanticRole::Menu => (0.60, 20.0, 1.30, [2.0, 8.0, 0.07]), + SemanticRole::Hud => (0.65, 18.0, 1.25, [1.0, 4.0, 0.04]), + SemanticRole::Notification => (0.58, 24.0, 1.35, [3.0, 10.0, 0.09]), + SemanticRole::Modal => (0.62, 28.0, 1.40, [3.0, 10.0, 0.09]), }; - - match style { - MaterialStyle::Glass => ResolvedMaterial { - style, - // Glass already contains an opaque copy of the backdrop. Its tint - // therefore needs less coverage than the no-capture translucent - // profile or the blur becomes visually indistinguishable from a - // flat alpha surface. - opacity: base_opacity * 0.72, - blur, - saturation, - brightness, - noise, + // Preserve the separately defined translucent family when selected + // directly. Glass degradation keeps the glass role's contrast floor. + let opacity = if style == MaterialStyle::Translucent { + match role { + SemanticRole::Panel => 0.78, + SemanticRole::Hud | SemanticRole::Modal => 0.88, + _ => 0.84, + } + } else { + opacity + }; + let style = if style == MaterialStyle::Glass { + match settings.quality { + GlassQuality::Solid => MaterialStyle::Solid, + GlassQuality::Translucent => MaterialStyle::Translucent, + _ => style, + } + } else { + style + }; + let glass = style == MaterialStyle::Glass; + ResolvedMaterial { + style, + opacity: if style == MaterialStyle::Solid { + 1.0 + } else { + opacity }, - MaterialStyle::Translucent => ResolvedMaterial { - style, - opacity: base_opacity, - blur: 0.0, - saturation: 1.0, - brightness: 1.0, - noise: 0.0, + blur: if !glass { + 0.0 + } else if settings.quality >= GlassQuality::ReducedBlur { + (blur * settings.blur_scale).min(12.0) + } else { + blur * settings.blur_scale }, - MaterialStyle::Solid => ResolvedMaterial { - style, - opacity: 1.0, - blur: 0.0, - saturation: 1.0, - brightness: 1.0, - noise: 0.0, + saturation: if glass && settings.quality < GlassQuality::NoSaturation { + saturation + } else { + 1.0 }, + brightness: 1.0, + noise: if glass && settings.grain && settings.quality < GlassQuality::NoGrain { + 0.05 + } else { + 0.0 + }, + shadow, } } @@ -297,13 +325,25 @@ mod tests { #[test] fn styles_preserve_semantics_but_disable_unsupported_effects() { - let glass = resolve_material(SemanticRole::Popover, MaterialStyle::Glass); - let translucent = resolve_material(SemanticRole::Popover, MaterialStyle::Translucent); - let solid = resolve_material(SemanticRole::Popover, MaterialStyle::Solid); + let glass = resolve_material( + SemanticRole::Popover, + MaterialStyle::Glass, + GlassSettings::default(), + ); + let translucent = resolve_material( + SemanticRole::Popover, + MaterialStyle::Translucent, + GlassSettings::default(), + ); + let solid = resolve_material( + SemanticRole::Popover, + MaterialStyle::Solid, + GlassSettings::default(), + ); assert!(glass.blur > 0.0); assert!(glass.noise > 0.0); - assert!(glass.opacity < translucent.opacity); + assert!(glass.opacity <= translucent.opacity); assert_eq!(translucent.blur, 0.0); assert_eq!(translucent.noise, 0.0); assert_eq!(solid.opacity, 1.0); @@ -311,11 +351,91 @@ mod tests { } #[test] - fn elevated_roles_resolve_more_strongly_than_the_panel() { - let panel = resolve_material(SemanticRole::Panel, MaterialStyle::Glass); - let popover = resolve_material(SemanticRole::Popover, MaterialStyle::Glass); + fn glass_presets_use_role_specific_tints_and_restrained_shadows() { + for (role, blur, saturation, opacity, shadow) in [ + (SemanticRole::Panel, 24.0, 1.35, 0.55, [1.0, 5.0, 0.04]), + (SemanticRole::Popover, 22.0, 1.35, 0.55, [2.0, 8.0, 0.07]), + (SemanticRole::Menu, 20.0, 1.30, 0.60, [2.0, 8.0, 0.07]), + (SemanticRole::Hud, 18.0, 1.25, 0.65, [1.0, 4.0, 0.04]), + ( + SemanticRole::Notification, + 24.0, + 1.35, + 0.58, + [3.0, 10.0, 0.09], + ), + (SemanticRole::Modal, 28.0, 1.40, 0.62, [3.0, 10.0, 0.09]), + ] { + let material = resolve_material(role, MaterialStyle::Glass, GlassSettings::default()); + assert_eq!( + ( + material.blur, + material.saturation, + material.opacity, + material.shadow + ), + (blur, saturation, opacity, shadow) + ); + } + } - assert!(popover.opacity > panel.opacity); - assert!(popover.blur > panel.blur); + #[test] + fn degradation_is_ordered_and_preserves_contrast_and_elevation() { + let reference = resolve_material( + SemanticRole::Modal, + MaterialStyle::Glass, + GlassSettings::default(), + ); + for quality in [ + GlassQuality::Full, + GlassQuality::NoGrain, + GlassQuality::NoSaturation, + GlassQuality::ReducedBlur, + GlassQuality::Translucent, + GlassQuality::Solid, + ] { + let material = resolve_material( + SemanticRole::Modal, + MaterialStyle::Glass, + GlassSettings { + quality, + ..GlassSettings::default() + }, + ); + assert!(material.opacity >= reference.opacity); + assert_eq!(material.shadow, reference.shadow); + if quality >= GlassQuality::NoGrain { + assert_eq!(material.noise, 0.0); + } + if quality >= GlassQuality::NoSaturation { + assert_eq!(material.saturation, 1.0); + } + if quality >= GlassQuality::ReducedBlur { + assert!(material.blur <= 12.0); + } + if quality >= GlassQuality::Translucent { + assert_eq!(material.blur, 0.0); + } + if quality == GlassQuality::Solid { + assert_eq!(material.opacity, 1.0); + } + } + } + + #[test] + fn popover_uses_the_same_tint_with_a_smaller_blur_than_panel() { + let panel = resolve_material( + SemanticRole::Panel, + MaterialStyle::Glass, + GlassSettings::default(), + ); + let popover = resolve_material( + SemanticRole::Popover, + MaterialStyle::Glass, + GlassSettings::default(), + ); + + assert_eq!(popover.opacity, panel.opacity); + assert!(popover.blur < panel.blur); } } diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index ab4aba5..a23f4bc 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -63,6 +63,10 @@ impl CompositorHandler for Ferese { } fn destroyed(&mut self, surface: &WlSurface) { + // A removed popup changes the backdrop even without a final buffer + // commit. Invalidate expanded sampling regions as well as its bounds. + self.invalidate_material_scene(); + crate::backends::direct::render_all(self); if self.idle_inhibitors.remove(surface).is_some() { self.idle_notifier_state .set_is_inhibited(!self.idle_inhibitors.is_empty()); diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index ca308af..827d38e 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -59,7 +59,10 @@ impl SeatHandler for Ferese { .or_insert_with(|| crate::cursor::load_named_cursor(&self.cursor_theme, *icon)); } self.cursor_status = image; - crate::backends::direct::render_all(self); + // Pointer focus transitions invoke this with the pointer lock held. + // render_all -> cursor_elements -> current_location would try to + // acquire that same lock, freezing the entire compositor thread. + self.cursor_redraw_pending = true; } fn focus_changed(&mut self, seat: &Seat, focused: Option<&WlSurface>) { diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 9f45f3f..219a11f 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -272,6 +272,10 @@ impl XdgShellHandler for Ferese { } if let Some(keyboard) = seat.get_keyboard() { keyboard.set_grab(self, PopupKeyboardGrab::new(&popup_grab), serial); + // Focus the popup immediately, rather than waiting for its first + // key event. Shell clients use focus to initialize popup effects, + // and Escape must be delivered to the popup from the outset. + keyboard.set_focus(self, popup_grab.current_grab(), serial); } } } diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 1712bea..ac42945 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -77,7 +77,19 @@ fn main() -> Result<(), Box> { info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities); - let result = event_loop.run(None, &mut state, |_| {}); + let result = event_loop.run(None, &mut state, |state| { + // All input/Wayland callbacks have returned, releasing seat locks. + // Coalesce cursor changes and redraw here, never inside cursor_image. + if std::mem::take(&mut state.cursor_redraw_pending) { + backends::direct::render_all(state); + } + // Registry/sync/configure replies must not depend on a submitted + // frame: at startup clients need these before they can draw anything. + // This also keeps idle DRM sessions responsive when there is no damage. + if let Err(error) = state.display_handle.flush_clients() { + tracing::warn!(%error, "failed to flush Wayland clients"); + } + }); if let Some(child) = &mut child { terminate_child(child); @@ -151,3 +163,71 @@ fn terminate_child(child: &mut Child) { let _ = child.kill(); let _ = child.wait(); } + +#[cfg(test)] +mod startup_tests { + use super::*; + use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + sync::Arc, + }; + + #[test] + fn deferred_cursor_redraw_runs_after_input_lock_is_released() { + use std::sync::Mutex; + + let mut event_loop = EventLoop::::try_new().unwrap(); + let pointer_lock = Arc::new(Mutex::new(())); + let callback_lock = pointer_lock.clone(); + event_loop.handle().insert_idle(move |pending| { + // Mirrors Smithay's cursor_image callback during pointer motion. + let _guard = callback_lock.lock().unwrap(); + *pending = true; + *pending = true; // Multiple cursor changes must coalesce. + assert!(callback_lock.try_lock().is_err()); + }); + + let signal = event_loop.get_signal(); + let mut pending = false; + let mut redraws = 0; + event_loop + .run(Some(Duration::ZERO), &mut pending, |pending| { + if std::mem::take(pending) { + // Rendering reads the pointer location at this point. + let _guard = pointer_lock.try_lock().expect("input lock still held"); + redraws += 1; + } + assert!(!std::mem::take(pending)); + signal.stop(); + }) + .unwrap(); + assert_eq!(redraws, 1); + } + + #[test] + fn wayland_roundtrip_completes_without_a_rendered_frame() { + let mut display = Display::<()>::new().unwrap(); + let (server, mut client) = UnixStream::pair().unwrap(); + display + .handle() + .insert_client(server, Arc::new(state::ClientState::default())) + .unwrap(); + // wl_display.sync(new_id=2), using native-endian Wayland wire format. + for word in [1u32, 12u32 << 16, 2u32] { + client.write_all(&word.to_ne_bytes()).unwrap(); + } + display.dispatch_clients(&mut ()).unwrap(); + display.handle().flush_clients().unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut reply = [0u8; 12]; + client.read_exact(&mut reply).unwrap(); + assert_eq!(u32::from_ne_bytes(reply[0..4].try_into().unwrap()), 2); + assert_eq!( + u32::from_ne_bytes(reply[4..8].try_into().unwrap()) & 0xffff, + 0 + ); + } +} diff --git a/compositor/src/private_client.rs b/compositor/src/private_client.rs index 4202be5..2102534 100644 --- a/compositor/src/private_client.rs +++ b/compositor/src/private_client.rs @@ -46,6 +46,9 @@ pub(crate) fn prepare_command( let mut clients = vec![primary]; command.env_remove("WAYLAND_DISPLAY"); + // Shell-launched applications must use the public socket, not inherit the + // private shell/effects connection or accidentally connect to the host. + command.env("FERESE_PUBLIC_WAYLAND_DISPLAY", &state.socket_name); command.env_remove("FERESE_SHELL_CONTROL_SOCKET"); command.env("WAYLAND_SOCKET", clients[0].as_raw_fd().to_string()); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index a08f869..badce98 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -147,6 +147,9 @@ pub struct Ferese { viewport_spring_config: SpringConfig, pub(crate) output_profiles: Vec, pub cursor_status: CursorImageStatus, + // Cursor callbacks may run with Smithay's pointer mutex held. Rendering + // reads the pointer position, so defer it until event dispatch returns. + pub(crate) cursor_redraw_pending: bool, pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) named_cursors: HashMap, pub intercepted_keys: HashSet, @@ -476,6 +479,7 @@ impl Ferese { viewport_spring_config: config.viewport_spring_config, output_profiles: config.output_profiles, cursor_status: CursorImageStatus::default_named(), + cursor_redraw_pending: false, cursor_theme, named_cursors, intercepted_keys: HashSet::new(), diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index bc99b5a..3fa5fc4 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -1,6 +1,10 @@ use std::{ collections::HashMap, error::Error, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, time::{Duration, Instant}, }; @@ -11,8 +15,7 @@ use smithay::{ Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture, damage::OutputDamageTracker, element::{ - AsRenderElements, Element, Id, Kind as RenderElementKind, RenderElement, - UnderlyingStorage, + Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage, memory::MemoryRenderBufferRenderElement, render_elements, solid::{SolidColorBuffer, SolidColorRenderElement}, @@ -40,9 +43,9 @@ use smithay::{ }, input::pointer::{CursorImageStatus, CursorImageSurfaceData}, output::{Mode, Output, PhysicalProperties, Scale, Subpixel}, + reexports::calloop::EventLoop, reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind, reexports::wayland_server::Resource, - reexports::calloop::EventLoop, utils::{ Buffer, Clock, Logical, Monotonic, Physical, Point, Rectangle, Scale as RenderScale, Size, Transform, @@ -94,7 +97,7 @@ const ROUNDED_TEXTURE_SHADER: &str = r#"#version 100 #extension GL_OES_EGL_image_external : require #endif -precision mediump float; +precision highp float; #if defined(EXTERNAL) uniform samplerExternalOES tex; #else @@ -218,23 +221,63 @@ void main() { } "#; +// The material program paints either the fallback tint, the edges above client +// content, or the shadow outside the surface. All coordinates are physical px. const MATERIAL_SHADER: &str = r#" -precision mediump float; - +precision highp float; uniform float alpha; uniform vec4 tint; -uniform float noise_amount; +uniform vec4 visible_rect; +uniform float material_radius; +uniform float pixel_scale; +uniform vec2 local_origin; +uniform vec4 local_axes; +uniform vec2 logical_size; +uniform float paint_mode; +uniform float glass_edges; +uniform float edge_bar; +uniform vec4 shadow_rect; +uniform vec2 shadow_values; varying vec2 v_coords; -float random(vec2 point) { - return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453); +float rounded_distance(vec2 point, vec4 rect) { + vec2 half_size = rect.zw * 0.5; + float radius = min(material_radius, min(half_size.x, half_size.y)); + vec2 d = abs(point - rect.xy - half_size) - (half_size - vec2(radius)); + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius; } - void main() { - float grain = (random(gl_FragCoord.xy) - 0.5) * noise_amount; - vec3 color = clamp(tint.rgb + vec3(grain), 0.0, 1.0); - float opacity = tint.a * alpha; - gl_FragColor = vec4(color * opacity, opacity); + float sdf = rounded_distance(gl_FragCoord.xy, visible_rect); + float coverage = 1.0 - smoothstep(-0.5, 0.5, sdf); + if (paint_mode > 1.5) { + float distance = max(rounded_distance(gl_FragCoord.xy, shadow_rect), 0.0); + float sigma = max(shadow_values.x * 0.5, 0.5); + float opacity = exp(-0.5 * distance * distance / (sigma * sigma)) + * shadow_values.y * (1.0 - coverage) * alpha; + gl_FragColor = vec4(0.0, 0.0, 0.0, opacity); + } else if (paint_mode > 0.5) { + if (edge_bar > 0.5) { + float bottom = logical_size.y * pixel_scale + - dot(gl_FragCoord.xy - local_origin, local_axes.zw); + float opacity = 0.10 * (1.0 - smoothstep(pixel_scale - 0.5, pixel_scale + 0.5, bottom)) * coverage * alpha; + gl_FragColor = vec4(vec3(opacity), opacity); + return; + } + float border = smoothstep(-pixel_scale - 0.5, -pixel_scale + 0.5, sdf); + float hairline = smoothstep(-2.0 * pixel_scale - 0.5, -2.0 * pixel_scale + 0.5, sdf) * (1.0 - border); + // Inset only on the logical top, including rotated/flipped outputs. + float top = dot(gl_FragCoord.xy - local_origin, local_axes.zw); + float top_limit = min(2.0 * pixel_scale, logical_size.y * pixel_scale); + float highlight = (1.0 - smoothstep(top_limit - 0.5, top_limit + 0.5, top)) * (1.0 - border); + float opacity = 1.0 - (1.0 - 0.14 * border) + * (1.0 - glass_edges * 0.04 * hairline) + * (1.0 - glass_edges * 0.18 * highlight); + opacity *= coverage * alpha; + gl_FragColor = vec4(vec3(opacity), opacity); + } else { + float opacity = tint.a * coverage * alpha; + gl_FragColor = vec4(tint.rgb * opacity, opacity); + } } "#; @@ -256,25 +299,35 @@ uniform float alpha; uniform vec4 visible_rect; uniform vec2 texture_size; uniform float blur_radius; +uniform float tap_scale; +uniform float material_radius; uniform float saturation; uniform float brightness; uniform vec4 tint; uniform float noise_amount; +uniform float pixel_scale; +uniform vec2 local_origin; +uniform vec4 local_axes; +uniform vec2 logical_size; varying vec2 v_coords; +// Static, surface-local two-octave fractal noise. A small polynomial hash +// avoids the precision-dependent sine hash and does not animate with time. float random(vec2 point) { - return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453); + vec3 p = fract(vec3(point.xyx) * 0.1031); + p += dot(p, p.yzx + 33.33); + return fract((p.x + p.y) * p.z); +} +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(random(i), random(i + vec2(1.0, 0.0)), f.x), + mix(random(i + vec2(0.0, 1.0)), random(i + vec2(1.0)), f.x), f.y); } -float kernel_weight(int tap) { - int distance = tap < 0 ? -tap : tap; - if (distance == 0) { - return 6.0; - } - if (distance == 1) { - return 4.0; - } - return 1.0; +float kernel_weight(float distance) { + return exp(-distance * distance / 18.0); } void main() { @@ -286,22 +339,41 @@ void main() { return; } - vec2 step_size = vec2(blur_radius * 0.5) / max(texture_size, vec2(1.0)); + vec2 step_size = vec2(blur_radius / 6.0) / max(texture_size, vec2(1.0)); vec4 color = vec4(0.0); - for (int y = -2; y <= 2; y++) { - for (int x = -2; x <= 2; x++) { - float weight = kernel_weight(x) * kernel_weight(y); - vec2 offset = vec2(float(x), float(y)) * step_size; + float weight_sum = 0.0; + for (int y = -6; y <= 6; y++) { + if (abs(float(y)) * tap_scale > 6.0) { continue; } + for (int x = -6; x <= 6; x++) { + if (abs(float(x)) * tap_scale > 6.0) { continue; } + float weight = kernel_weight(float(x) * tap_scale) * kernel_weight(float(y) * tap_scale); + vec2 offset = vec2(float(x), float(y)) * step_size * tap_scale; color += texture2D(tex, v_coords + offset) * weight; + weight_sum += weight; } } - color /= 256.0; + color /= weight_sum; float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); vec3 adjusted = mix(vec3(luma), color.rgb, saturation) * brightness; adjusted = mix(adjusted, tint.rgb, tint.a); - adjusted += vec3((random(point) - 0.5) * noise_amount); - gl_FragColor = vec4(clamp(adjusted, 0.0, 1.0) * alpha, alpha); + vec2 relative = point - local_origin; + vec2 local = vec2(dot(relative, local_axes.xy), dot(relative, local_axes.zw)) / pixel_scale; + vec2 size = logical_size; + // CSS 155deg: direction (sin(155deg), -cos(155deg)). Project onto + // the full gradient line, including the aspect ratio of the surface. + vec2 direction = vec2(0.422618, 0.906308); + float position = dot(local, direction) / max(dot(size, direction), 1.0); + adjusted = mix(adjusted, vec3(1.0), 0.10 * clamp(1.0 - position / 0.40, 0.0, 1.0)); + float grain = (noise(local * 0.8) + 0.5 * noise(local * 1.6)) / 1.5; + vec3 overlay = mix(2.0 * adjusted * grain, + 1.0 - 2.0 * (1.0 - adjusted) * (1.0 - grain), step(vec3(0.5), adjusted)); + adjusted = mix(adjusted, overlay, noise_amount); + vec2 half_size = visible_rect.zw * 0.5; + vec2 distance = abs(point - visible_rect.xy - half_size) - (half_size - vec2(material_radius)); + float sdf = length(max(distance, 0.0)) + min(max(distance.x, distance.y), 0.0) - material_radius; + float coverage = alpha * (1.0 - smoothstep(-0.5, 0.5, sdf)); + gl_FragColor = vec4(clamp(adjusted, 0.0, 1.0) * coverage, coverage); } "#; @@ -333,6 +405,16 @@ pub(crate) struct BackdropProgram(GlesTexProgram); #[derive(Clone, Debug, PartialEq)] struct MaterialParameters { + radius: f32, + pixel_scale: f32, + local_origin: [f32; 2], + local_axes: [f32; 4], + logical_size: [f32; 2], + glass_edges: f32, + edge_bar: f32, + shadow_rect: [f32; 4], + shadow_values: [f32; 2], + shadow_bounds: Rectangle, geometry: Rectangle, tint: [f32; 4], noise: f32, @@ -340,6 +422,7 @@ struct MaterialParameters { scene_generation: u64, opaque: bool, blur: f32, + tap_scale: f32, saturation: f32, brightness: f32, sample_geometry: Rectangle, @@ -352,6 +435,8 @@ struct MaterialParameters { struct CachedMaterial { element: CachedMaterialElement, parameters: MaterialParameters, + edge: PixelShaderElement, + shadow: PixelShaderElement, } #[derive(Clone, Debug)] @@ -367,6 +452,7 @@ pub(crate) struct MaterialBuffers { #[derive(Clone, Debug)] struct BackdropRenderElement { + capture_dirty: Arc, texture: GlesTexture, program: GlesTexProgram, id: Id, @@ -379,6 +465,7 @@ struct BackdropRenderElement { impl BackdropRenderElement { fn new(texture: GlesTexture, program: GlesTexProgram, parameters: &MaterialParameters) -> Self { Self { + capture_dirty: Arc::new(AtomicBool::new(true)), texture, program, id: Id::new(), @@ -390,6 +477,7 @@ impl BackdropRenderElement { } fn update(&mut self, parameters: &MaterialParameters) { + self.capture_dirty.store(true, Ordering::Relaxed); self.geometry = parameters.sample_geometry; self.capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer); self.uniforms = backdrop_uniforms(parameters); @@ -419,11 +507,7 @@ impl Element for BackdropRenderElement { scale: RenderScale, commit: Option, ) -> DamageSet { - if commit == Some(self.commit) { - DamageSet::default() - } else { - DamageSet::from_slice(&[Rectangle::from_size(self.geometry(scale).size)]) - } + backdrop_damage(self.geometry(scale).size, self.commit, commit) } fn opaque_regions(&self, _scale: RenderScale) -> OpaqueRegions { @@ -441,6 +525,19 @@ impl Element for BackdropRenderElement { } } +fn backdrop_damage( + size: Size, + current: CommitCounter, + previous: Option, +) -> DamageSet { + if previous == Some(current) { + DamageSet::default() + } else { + // A new scene needs its entire sampling halo repainted before capture. + DamageSet::from_slice(&[Rectangle::from_size(size)]) + } +} + impl RenderElement for BackdropRenderElement { fn draw( &self, @@ -452,30 +549,36 @@ impl RenderElement for BackdropRenderElement { ) -> Result<(), GlesError> { let texture = self.texture.tex_id(); let [x, y, width, height] = self.capture_rect; - frame.with_context(|gl| unsafe { - gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, texture); - gl.TexParameteri( - smithay::backend::renderer::gles::ffi::TEXTURE_2D, - smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_S, - smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32, - ); - gl.TexParameteri( - smithay::backend::renderer::gles::ffi::TEXTURE_2D, - smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_T, - smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32, - ); - gl.CopyTexSubImage2D( - smithay::backend::renderer::gles::ffi::TEXTURE_2D, - 0, - 0, - 0, - x, - y, - width, - height, - ); - gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, 0); - })?; + // Buffer-age restoration and unrelated partial damage must reuse the + // clean captured scene. Recapturing a partly repainted framebuffer + // would feed previous glass pixels back into the blur. + if self.capture_dirty.load(Ordering::Relaxed) { + frame.with_context(|gl| unsafe { + gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, texture); + gl.TexParameteri( + smithay::backend::renderer::gles::ffi::TEXTURE_2D, + smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_S, + smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32, + ); + gl.TexParameteri( + smithay::backend::renderer::gles::ffi::TEXTURE_2D, + smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_T, + smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32, + ); + gl.CopyTexSubImage2D( + smithay::backend::renderer::gles::ffi::TEXTURE_2D, + 0, + 0, + 0, + x, + y, + width, + height, + ); + gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, 0); + })?; + self.capture_dirty.store(false, Ordering::Relaxed); + } frame.render_texture_from_to( &self.texture, @@ -1452,45 +1555,149 @@ fn layer_elements( let mut elements = Vec::new(); for (geometry, layer) in layers { - elements.extend( - AsRenderElements::::render_elements::< - WaylandSurfaceRenderElement, - >( - &layer, + for (popup, offset) in PopupManager::popups_for_surface(layer.wl_surface()) { + let mut bounds = popup.geometry(); + let content_origin = geometry.loc + offset - bounds.loc; + bounds.loc = geometry.loc + offset; + append_material_surface( + state, renderer, - geometry.loc.to_physical_precise_round(scale), - scale.into(), - 1.0, - ) - .into_iter() - .filter_map(|element| { - let origin = Point::::default(); - let element = RescaleRenderElement::from_element(element, origin, 1.0); - let element = - RelocateRenderElement::from_element(element, origin, Relocate::Relative); - - CropRenderElement::from_element(element, scale, output_crop).map(Into::into) - }), - ); - - if let Some(material) = material_element(state, renderer, output, &layer, geometry) { - elements.push(material); + output, + popup.wl_surface(), + bounds, + content_origin, + scale, + output_crop, + &mut elements, + ); } + append_material_surface( + state, + renderer, + output, + layer.wl_surface(), + geometry, + geometry.loc, + scale, + output_crop, + &mut elements, + ); } elements } +#[allow(clippy::too_many_arguments)] +fn append_material_surface( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, + geometry: Rectangle, + content_origin: Point, + scale: f64, + output_crop: Rectangle, + elements: &mut Vec, +) { + let radius = if crate::effects::surface_role(surface) + .is_some_and(|(role, _)| role == crate::effects::SemanticRole::Panel) + { + state.theme_settings.panel_radius + } else { + state.theme_settings.material_radius + }; + let material = material_element(state, renderer, output, surface, geometry, radius as f32); + let clip = material.as_ref().and_then(|_| { + let mode = output.current_mode()?; + let program = rounded_clip_program(state, renderer)?; + Some(( + program.texture, + framebuffer_clip_rect( + geometry.to_physical_precise_round(scale), + mode.size, + output.current_transform().invert(), + ), + scaled_effect_value(radius, geometry, scale), + )) + }); + // Smithay element lists are front to back: edges, content, material, shadow. + let material = material.map(|(edge, background, shadow)| { + elements.push(edge); + (background, shadow) + }); + let content = render_elements_from_surface_tree::< + GlesRenderer, + WaylandSurfaceRenderElement, + >( + renderer, + surface, + content_origin.to_physical_precise_round(scale), + scale, + 1.0, + RenderElementKind::Unspecified, + ); + elements.extend(content.into_iter().filter_map(|element| { + let origin = Point::::default(); + let element = if let Some((program, clip_rect, radius)) = &clip { + WindowContentRenderElement::Rounded(RoundedSurfaceRenderElement { + inner: element, + program: program.clone(), + clip_rect: *clip_rect, + radius: *radius, + clip_changed: false, + }) + } else { + WindowContentRenderElement::Popup(element) + }; + let element = RescaleRenderElement::from_element(element, origin, 1.0); + let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative); + CropRenderElement::from_element(element, scale, output_crop).map(Into::into) + })); + if let Some((background, shadow)) = material { + elements.push(background); + elements.push(shadow); + } +} + fn material_element( state: &mut Ferese, renderer: &mut GlesRenderer, output: &Output, - layer: &LayerSurface, + surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, geometry: Rectangle, -) -> Option { - let surface = layer.wl_surface(); + radius: f32, +) -> Option<( + AnimatedWindowRenderElement, + AnimatedWindowRenderElement, + AnimatedWindowRenderElement, +)> { + material_element_for_style( + state, + renderer, + output, + surface, + geometry, + radius, + state.theme_settings.material_style, + ) +} + +#[allow(clippy::too_many_arguments)] +fn material_element_for_style( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, + geometry: Rectangle, + radius: f32, + style: crate::config::MaterialStyle, +) -> Option<( + AnimatedWindowRenderElement, + AnimatedWindowRenderElement, + AnimatedWindowRenderElement, +)> { let (role, generation) = crate::effects::surface_role(surface)?; - let material = crate::effects::resolve_material(role, state.theme_settings.material_style); + let material = crate::effects::resolve_material(role, style, state.theme_settings.glass); let mode = output.current_mode()?; let scale = output.current_scale().fractional_scale(); let output_logical = smithay::utils::Size::::from(( @@ -1501,9 +1708,46 @@ fn material_element( expanded_blur_region(geometry, material.blur.ceil() as i32, output_logical); let sample_physical = sample_geometry.to_physical_precise_round(scale); let transform = output.current_transform().invert(); - let [red, green, blue, _] = state.theme_settings.surface_base_color.0; + let [red, green, blue, _] = + if state.theme_settings.material_style == crate::config::MaterialStyle::Glass { + state.theme_settings.glass.tint.0 + } else { + state.theme_settings.surface_base_color.0 + }; + let radius = radius.min(geometry.size.w.min(geometry.size.h) as f32 * 0.5); + let [offset_y, shadow_blur, shadow_opacity] = material.shadow; + let edge_bar = + role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0; + let shadow_opacity = if edge_bar { 0.0 } else { shadow_opacity }; + let shadow_geometry = Rectangle::new( + (geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), + geometry.size, + ); let tint = [red, green, blue, material.opacity]; + let (local_origin, local_axes) = material_local_coordinates( + geometry.loc.to_physical_precise_round(scale), + mode.size, + transform, + ); let parameters = MaterialParameters { + radius: radius * scale as f32, + pixel_scale: scale as f32, + local_origin, + local_axes, + logical_size: [geometry.size.w as f32, geometry.size.h as f32], + edge_bar: if edge_bar { 1.0 } else { 0.0 }, + glass_edges: if material.style == crate::config::MaterialStyle::Glass { + 1.0 + } else { + 0.0 + }, + shadow_rect: framebuffer_clip_rect( + shadow_geometry.to_physical_precise_round(scale), + mode.size, + transform, + ), + shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32], + shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur), geometry, tint, noise: material.noise, @@ -1511,8 +1755,14 @@ fn material_element( scene_generation: matches!(material.style, crate::config::MaterialStyle::Glass) .then(|| state.material_scene_generation()) .unwrap_or(0), - opaque: material.opacity == 1.0, + opaque: material.opacity == 1.0 && radius == 0.0, blur: material.blur * scale as f32, + tap_scale: if state.theme_settings.glass.quality >= crate::config::GlassQuality::ReducedBlur + { + 2.0 + } else { + 1.0 + }, saturation: material.saturation, brightness: material.brightness, sample_geometry, @@ -1574,6 +1824,21 @@ fn material_element( } }) .flatten(); + if needs_replacement + && replacement.is_none() + && material.style == crate::config::MaterialStyle::Glass + { + return material_element_for_style( + state, + renderer, + output, + surface, + geometry, + radius, + crate::config::MaterialStyle::Translucent, + ); + } + let decoration_program = material_program(state, renderer)?; let buffers = state.material_buffers.entry(surface.clone()).or_default(); if !buffers.contexts.contains_key(&context) { buffers.contexts.insert( @@ -1581,12 +1846,39 @@ fn material_element( CachedMaterial { element: replacement.take()?, parameters: parameters.clone(), + edge: PixelShaderElement::new( + decoration_program.0.clone(), + geometry, + None, + 1.0, + decoration_uniforms(¶meters, 1.0), + RenderElementKind::Unspecified, + ), + shadow: PixelShaderElement::new( + decoration_program.0.clone(), + parameters.shadow_bounds, + None, + 1.0, + decoration_uniforms(¶meters, 2.0), + RenderElementKind::Unspecified, + ), }, ); } let cached = buffers.contexts.get_mut(&context)?; + if let Some(replacement) = replacement.take() { + cached.element = replacement; + } if cached.parameters != parameters { + cached.edge.resize(geometry, None); + cached + .edge + .update_uniforms(decoration_uniforms(¶meters, 1.0)); + cached.shadow.resize(parameters.shadow_bounds, None); + cached + .shadow + .update_uniforms(decoration_uniforms(¶meters, 2.0)); match &mut cached.element { CachedMaterialElement::Tint(element) => { if cached.parameters.geometry != parameters.geometry @@ -1599,20 +1891,22 @@ fn material_element( } element.update_uniforms(material_uniforms(¶meters)); } - CachedMaterialElement::Backdrop(element) - if cached.parameters.sample_physical.size == parameters.sample_physical.size => - { + CachedMaterialElement::Backdrop(element) => { element.update(¶meters); } - _ => cached.element = replacement.take()?, } cached.parameters = parameters; } - Some(match &cached.element { + let background = match &cached.element { CachedMaterialElement::Tint(element) => element.clone().into(), CachedMaterialElement::Backdrop(element) => element.clone().into(), - }) + }; + Some(( + cached.edge.clone().into(), + background, + cached.shadow.clone().into(), + )) } fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { @@ -1622,8 +1916,18 @@ fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { @@ -1646,12 +1950,18 @@ fn backdrop_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option { @@ -1667,14 +1977,33 @@ fn backdrop_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option Vec> { + decoration_uniforms(parameters, 0.0) +} + +fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f32) -> Vec> { vec![ + Uniform::new("pixel_scale", parameters.pixel_scale).into_owned(), + Uniform::new("local_origin", parameters.local_origin).into_owned(), + Uniform::new("local_axes", parameters.local_axes).into_owned(), + Uniform::new("logical_size", parameters.logical_size).into_owned(), + Uniform::new("paint_mode", paint_mode).into_owned(), + Uniform::new("glass_edges", parameters.glass_edges).into_owned(), + Uniform::new("edge_bar", parameters.edge_bar).into_owned(), + Uniform::new("shadow_rect", parameters.shadow_rect).into_owned(), + Uniform::new("shadow_values", parameters.shadow_values).into_owned(), + Uniform::new("visible_rect", parameters.visible_framebuffer).into_owned(), + Uniform::new("material_radius", parameters.radius).into_owned(), Uniform::new("tint", parameters.tint).into_owned(), - Uniform::new("noise_amount", parameters.noise).into_owned(), ] } fn backdrop_uniforms(parameters: &MaterialParameters) -> Vec> { vec![ + Uniform::new("pixel_scale", parameters.pixel_scale).into_owned(), + Uniform::new("local_origin", parameters.local_origin).into_owned(), + Uniform::new("local_axes", parameters.local_axes).into_owned(), + Uniform::new("logical_size", parameters.logical_size).into_owned(), + Uniform::new("material_radius", parameters.radius).into_owned(), Uniform::new("visible_rect", parameters.visible_framebuffer).into_owned(), Uniform::new( "texture_size", @@ -1685,6 +2014,7 @@ fn backdrop_uniforms(parameters: &MaterialParameters) -> Vec> { ) .into_owned(), Uniform::new("blur_radius", parameters.blur).into_owned(), + Uniform::new("tap_scale", parameters.tap_scale).into_owned(), Uniform::new("saturation", parameters.saturation).into_owned(), Uniform::new("brightness", parameters.brightness).into_owned(), Uniform::new("tint", parameters.tint).into_owned(), @@ -1692,6 +2022,21 @@ fn backdrop_uniforms(parameters: &MaterialParameters) -> Vec> { ] } +fn material_local_coordinates( + origin: Point, + output_size: Size, + transform: Transform, +) -> ([f32; 2], [f32; 4]) { + let area = transform.transform_size(output_size); + let mapped = transform.transform_point_in(origin, &area); + let x = transform.transform_point_in(origin + Point::from((1, 0)), &area) - mapped; + let y = transform.transform_point_in(origin + Point::from((0, 1)), &area) - mapped; + ( + [mapped.x as f32, mapped.y as f32], + [x.x as f32, x.y as f32, y.x as f32, y.y as f32], + ) +} + fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] { [ rect[0].round() as i32, @@ -1809,18 +2154,20 @@ mod tests { element::{Element, Id}, utils::CommitCounter, }, - utils::{Buffer, Logical, Physical, Rectangle, Scale, Transform}, + utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Size, Transform}, }; use super::{ color_with_alpha, expanded_blur_region, framebuffer_capture_rect, framebuffer_clip_rect, - normalized_scale, rounded_visual_rect, scaled_visual_rect, shadow_bounds, + material_local_coordinates, normalized_scale, rounded_visual_rect, scaled_visual_rect, + shadow_bounds, }; #[derive(Debug)] struct DamageElement { id: Id, geometry: Rectangle, + commit: CommitCounter, } impl DamageElement { @@ -1828,6 +2175,7 @@ mod tests { Self { id: Id::new(), geometry, + commit: CommitCounter::default(), } } } @@ -1838,7 +2186,7 @@ mod tests { } fn current_commit(&self) -> CommitCounter { - CommitCounter::default() + self.commit } fn src(&self) -> Rectangle { @@ -1853,6 +2201,38 @@ mod tests { fn geometry(&self, scale: Scale) -> Rectangle { self.geometry.to_physical_precise_round(scale) } + + fn damage_since( + &self, + scale: Scale, + commit: Option, + ) -> smithay::backend::renderer::utils::DamageSet { + super::backdrop_damage(self.geometry(scale).size, self.current_commit(), commit) + } + } + + #[test] + fn backdrop_is_idle_until_scene_changes_then_repaints_full_sampling_region() { + for scale in [1.0, 1.25, 1.8, 2.0] { + for transform in [Transform::Normal, Transform::Flipped180] { + let mut tracker = OutputDamageTracker::new((1600, 1200), scale, transform); + let mut backdrop = + DamageElement::new(Rectangle::new((50, 40).into(), (400, 240).into())); + tracker.damage_output(0, &[&backdrop]).unwrap(); + for _ in 0..3 { + assert!(tracker.damage_output(1, &[&backdrop]).unwrap().0.is_none()); + backdrop.commit.increment(); + let damage = tracker + .damage_output(1, &[&backdrop]) + .unwrap() + .0 + .cloned() + .unwrap(); + let bounds = backdrop.geometry(scale.into()); + assert!(damage.iter().any(|rect| rect.contains_rect(bounds))); + } + } + } } #[test] @@ -1877,6 +2257,42 @@ mod tests { ); } + #[test] + fn glass_lighting_coordinates_follow_every_output_transform() { + let origin = Point::::from((120, 90)); + let size = Size::::from((1000, 800)); + for transform in [ + Transform::Normal, + Transform::_90, + Transform::_180, + Transform::_270, + Transform::Flipped, + Transform::Flipped90, + Transform::Flipped180, + Transform::Flipped270, + ] { + let (mapped_origin, axes) = material_local_coordinates(origin, size, transform); + for local in [(0, 0), (24, 0), (0, 35), (57, 81)] { + let point = transform.transform_point_in( + origin + Point::from(local), + &transform.transform_size(size), + ); + let relative = [ + point.x as f32 - mapped_origin[0], + point.y as f32 - mapped_origin[1], + ]; + assert_eq!( + relative[0] * axes[0] + relative[1] * axes[1], + local.0 as f32 + ); + assert_eq!( + relative[0] * axes[2] + relative[1] * axes[3], + local.1 as f32 + ); + } + } + } + #[test] fn clip_rect_follows_output_transform() { let geometry = Rectangle::::new((10, 5).into(), (30, 40).into()); diff --git a/packaging/config.toml b/packaging/config.toml index df3b4f6..a871e33 100644 --- a/packaging/config.toml +++ b/packaging/config.toml @@ -10,5 +10,12 @@ focus_follows_mouse = false [layout] smart_gaps = false +# Edge-to-edge menu bar; its foreground palette is independent of popup surfaces. +[theme.surface.bar] +background = "#1C202E" +opacity = 0.95 +text_primary = "#F0F3FA" +text_muted = "#AAB4C7" + # Super+Enter opens Foot. Super+Shift+E logs out. # See README.md for monitor profiles and additional bindings. diff --git a/shell/ferese-shell/assets/icons/battery.svg b/shell/ferese-shell/assets/icons/battery.svg index ea2b996..9d5e5fd 100644 --- a/shell/ferese-shell/assets/icons/battery.svg +++ b/shell/ferese-shell/assets/icons/battery.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/shell/ferese-shell/assets/icons/ferese.svg b/shell/ferese-shell/assets/icons/ferese.svg new file mode 100644 index 0000000..197b023 --- /dev/null +++ b/shell/ferese-shell/assets/icons/ferese.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/overview.svg b/shell/ferese-shell/assets/icons/overview.svg index 2cbd4c9..ee08e88 100644 --- a/shell/ferese-shell/assets/icons/overview.svg +++ b/shell/ferese-shell/assets/icons/overview.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/shell/ferese-shell/assets/icons/status/airplane.svg b/shell/ferese-shell/assets/icons/status/airplane.svg new file mode 100644 index 0000000..abbd7bc --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/airplane.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-25.svg b/shell/ferese-shell/assets/icons/status/battery-25.svg new file mode 100644 index 0000000..75774e1 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/battery-25.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-50.svg b/shell/ferese-shell/assets/icons/status/battery-50.svg new file mode 100644 index 0000000..ad74dd0 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/battery-50.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-75.svg b/shell/ferese-shell/assets/icons/status/battery-75.svg new file mode 100644 index 0000000..25168ef --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/battery-75.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-charging.svg b/shell/ferese-shell/assets/icons/status/battery-charging.svg new file mode 100644 index 0000000..a4b3f05 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/battery-charging.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-empty.svg b/shell/ferese-shell/assets/icons/status/battery-empty.svg new file mode 100644 index 0000000..0a88b4c --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/battery-empty.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/battery-full.svg b/shell/ferese-shell/assets/icons/status/battery-full.svg new file mode 100644 index 0000000..9d5e5fd --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/battery-full.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-connected.svg b/shell/ferese-shell/assets/icons/status/bluetooth-connected.svg new file mode 100644 index 0000000..81b3302 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/bluetooth-connected.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-off.svg b/shell/ferese-shell/assets/icons/status/bluetooth-off.svg new file mode 100644 index 0000000..e228222 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/bluetooth-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/bluetooth-on.svg b/shell/ferese-shell/assets/icons/status/bluetooth-on.svg new file mode 100644 index 0000000..bbfff7f --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/bluetooth-on.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/ferese-shell/assets/icons/status/brightness.svg b/shell/ferese-shell/assets/icons/status/brightness.svg new file mode 100644 index 0000000..3eee0a5 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/brightness.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/chevron-down.svg b/shell/ferese-shell/assets/icons/status/chevron-down.svg new file mode 100644 index 0000000..59d636d --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/ferese-shell/assets/icons/status/control-center.svg b/shell/ferese-shell/assets/icons/status/control-center.svg new file mode 100644 index 0000000..b5b9603 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/control-center.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/dnd.svg b/shell/ferese-shell/assets/icons/status/dnd.svg new file mode 100644 index 0000000..d50361e --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/dnd.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/ferese-shell/assets/icons/status/ethernet.svg b/shell/ferese-shell/assets/icons/status/ethernet.svg new file mode 100644 index 0000000..5cf8968 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/ethernet.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/ferese-shell/assets/icons/status/lock.svg b/shell/ferese-shell/assets/icons/status/lock.svg new file mode 100644 index 0000000..55a4744 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/mic-mute.svg b/shell/ferese-shell/assets/icons/status/mic-mute.svg new file mode 100644 index 0000000..3b51357 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/mic-mute.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/mic.svg b/shell/ferese-shell/assets/icons/status/mic.svg new file mode 100644 index 0000000..dedb7e3 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/mic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/moon.svg b/shell/ferese-shell/assets/icons/status/moon.svg new file mode 100644 index 0000000..d50361e --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/ferese-shell/assets/icons/status/notifications-off.svg b/shell/ferese-shell/assets/icons/status/notifications-off.svg new file mode 100644 index 0000000..158cb71 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/notifications-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/notifications.svg b/shell/ferese-shell/assets/icons/status/notifications.svg new file mode 100644 index 0000000..9c5deaa --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/notifications.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/power.svg b/shell/ferese-shell/assets/icons/status/power.svg new file mode 100644 index 0000000..2a289f1 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/power.svg @@ -0,0 +1,3 @@ + + + diff --git a/shell/ferese-shell/assets/icons/status/volume-high.svg b/shell/ferese-shell/assets/icons/status/volume-high.svg new file mode 100644 index 0000000..2cf5669 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/volume-high.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/volume-low.svg b/shell/ferese-shell/assets/icons/status/volume-low.svg new file mode 100644 index 0000000..f07b4d1 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/volume-low.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/volume-medium.svg b/shell/ferese-shell/assets/icons/status/volume-medium.svg new file mode 100644 index 0000000..036c799 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/volume-medium.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/status/volume-mute.svg b/shell/ferese-shell/assets/icons/status/volume-mute.svg new file mode 100644 index 0000000..ff803e8 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/volume-mute.svg @@ -0,0 +1,4 @@ + + + + diff --git a/shell/ferese-shell/assets/icons/status/wifi-full.svg b/shell/ferese-shell/assets/icons/status/wifi-full.svg new file mode 100644 index 0000000..8d83c72 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/wifi-full.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/wifi-low.svg b/shell/ferese-shell/assets/icons/status/wifi-low.svg new file mode 100644 index 0000000..b37e417 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/wifi-low.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/wifi-medium.svg b/shell/ferese-shell/assets/icons/status/wifi-medium.svg new file mode 100644 index 0000000..f50e12a --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/wifi-medium.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shell/ferese-shell/assets/icons/status/wifi-off.svg b/shell/ferese-shell/assets/icons/status/wifi-off.svg new file mode 100644 index 0000000..9f7571c --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/wifi-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/shell/ferese-shell/assets/icons/volume.svg b/shell/ferese-shell/assets/icons/volume.svg index 7fa4a7f..2cf5669 100644 --- a/shell/ferese-shell/assets/icons/volume.svg +++ b/shell/ferese-shell/assets/icons/volume.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/shell/ferese-shell/assets/icons/wifi.svg b/shell/ferese-shell/assets/icons/wifi.svg index 742a4a9..8d83c72 100644 --- a/shell/ferese-shell/assets/icons/wifi.svg +++ b/shell/ferese-shell/assets/icons/wifi.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 986c966..28bc6cf 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -11,11 +11,34 @@ pub(crate) struct ShellConfig { pub(crate) font_family: Option, pub(crate) wallpaper: WallpaperConfig, pub(crate) theme: ShellTheme, + pub(crate) status: StatusConfig, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +pub(crate) struct StatusConfig { + pub(crate) battery_percentage: bool, + pub(crate) low_battery_threshold: u8, + pub(crate) settings_command: Option>, +} + +impl Default for StatusConfig { + fn default() -> Self { + Self { + battery_percentage: true, + low_battery_threshold: 20, + settings_command: None, + } + } } #[derive(Clone, Copy, Debug)] pub(crate) struct ShellTheme { + pub(crate) bar_background: [u8; 4], + pub(crate) bar_text_primary: [u8; 4], + pub(crate) bar_text_muted: [u8; 4], pub(crate) surface_base: [u8; 4], + pub(crate) surface_popover: [u8; 4], pub(crate) text_primary: [u8; 4], pub(crate) text_muted: [u8; 4], pub(crate) accent: [u8; 4], @@ -23,8 +46,10 @@ pub(crate) struct ShellTheme { pub(crate) shadow: [u8; 4], pub(crate) bar_height: f32, pub(crate) bar_margin_top: i32, + pub(crate) bar_window_gap: i32, pub(crate) bar_margin_horizontal: i32, pub(crate) bar_radius: f32, + pub(crate) material_radius: f32, pub(crate) panel_padding: f32, pub(crate) control_gap: f32, pub(crate) shadow_offset_y: f32, @@ -35,18 +60,24 @@ pub(crate) struct ShellTheme { impl Default for ShellTheme { fn default() -> Self { Self { + bar_background: [28, 32, 46, 242], + bar_text_primary: [240, 243, 250, 255], + bar_text_muted: [170, 180, 199, 255], surface_base: [17, 24, 33, 255], + surface_popover: [17, 24, 33, 245], text_primary: [244, 247, 251, 255], text_muted: [127, 138, 152, 255], accent: [91, 140, 255, 255], border: [255, 255, 255, 24], shadow: [0, 0, 0, 85], - bar_height: 38.0, - bar_margin_top: 4, - bar_margin_horizontal: 10, - bar_radius: 19.0, + bar_height: 28.0, + bar_margin_top: 0, + bar_window_gap: 8, + bar_margin_horizontal: 0, + bar_radius: 0.0, + material_radius: 14.0, panel_padding: 12.0, - control_gap: 8.0, + control_gap: 12.0, shadow_offset_y: 4.0, shadow_blur: 18.0, shadow_opacity: 0.20, @@ -54,6 +85,15 @@ impl Default for ShellTheme { } } +impl ShellTheme { + // The bar has its own foreground tokens, matched to its dark glass surface. + pub(crate) fn for_bar(mut self) -> Self { + self.text_primary = self.bar_text_primary; + self.text_muted = self.bar_text_muted; + self + } +} + #[derive(Clone, Debug, Default, Deserialize)] pub(crate) struct WallpaperConfig { pub(crate) path: Option, @@ -71,12 +111,23 @@ pub(crate) enum WallpaperMode { #[derive(Debug, Default, Deserialize)] struct FereseConfig { + #[serde(default)] + appearance: AppearanceConfig, #[serde(default)] theme: ThemeConfig, + #[serde(default)] + status: StatusConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct AppearanceConfig { + corner_radius: Option, } #[derive(Debug, Default, Deserialize)] struct ThemeConfig { + #[serde(default)] + surface: SurfaceConfig, #[serde(default)] colors: ThemeColorsConfig, #[serde(default)] @@ -89,6 +140,50 @@ struct ThemeConfig { background: WallpaperConfig, } +#[derive(Debug, Default, Deserialize)] +struct SurfaceConfig { + #[serde(default)] + bar: BarConfig, + #[serde(default)] + popover: PopoverConfig, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +struct BarConfig { + background: String, + // If omitted, preserve the alpha supplied in `background`. + opacity: Option, + text_primary: String, + text_muted: String, +} + +impl Default for BarConfig { + fn default() -> Self { + Self { + background: "#1C202EF2".to_owned(), + opacity: None, + text_primary: "#F0F3FA".to_owned(), + text_muted: "#AAB4C7".to_owned(), + } + } +} +#[derive(Debug, Deserialize)] +struct PopoverConfig { + #[serde(default = "default_popover_opacity")] + opacity: f32, +} +impl Default for PopoverConfig { + fn default() -> Self { + Self { + opacity: default_popover_opacity(), + } + } +} +const fn default_popover_opacity() -> f32 { + 0.96 +} + #[derive(Debug, Default, Deserialize)] struct TypographyConfig { font_family: Option, @@ -129,6 +224,8 @@ struct ThemeGeometryConfig { top_bar_height: f32, #[serde(default = "default_bar_margin_top")] top_bar_margin_top: i32, + #[serde(default = "default_bar_window_gap")] + top_bar_window_gap: i32, #[serde(default = "default_bar_margin_horizontal")] top_bar_margin_horizontal: i32, #[serde(default = "default_bar_radius")] @@ -144,6 +241,7 @@ impl Default for ThemeGeometryConfig { Self { top_bar_height: default_bar_height(), top_bar_margin_top: default_bar_margin_top(), + top_bar_window_gap: default_bar_window_gap(), top_bar_margin_horizontal: default_bar_margin_horizontal(), top_bar_radius: default_bar_radius(), panel_padding: default_panel_padding(), @@ -188,12 +286,18 @@ pub(crate) fn load() -> ShellConfig { match toml::from_str::(&source) { Ok(config) => { - let theme = shell_theme(&config.theme); + let mut theme = shell_theme(&config.theme); + theme.material_radius = + nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0); ShellConfig { font_family: config.theme.typography.font_family, wallpaper: config.theme.background, theme, + status: StatusConfig { + low_battery_threshold: config.status.low_battery_threshold.min(100), + ..config.status + }, } } Err(error) => { @@ -207,7 +311,31 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme { let defaults = ShellTheme::default(); ShellTheme { + material_radius: defaults.material_radius, + bar_background: { + let mut background = + parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background); + if let Some(opacity) = theme.surface.bar.opacity { + background[3] = + (finite_or(opacity, f32::from(background[3]) / 255.0).clamp(0.0, 1.0) * 255.0) + .round() as u8; + } + background + }, + bar_text_primary: parse_color(&theme.surface.bar.text_primary) + .unwrap_or(defaults.bar_text_primary), + bar_text_muted: parse_color(&theme.surface.bar.text_muted) + .unwrap_or(defaults.bar_text_muted), surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base), + surface_popover: { + let mut color = + parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base); + color[3] = (finite_or(theme.surface.popover.opacity, default_popover_opacity()) + .clamp(0.0, 1.0) + * 255.0) + .round() as u8; + color + }, text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary), text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted), accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent), @@ -215,13 +343,15 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme { shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow), bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height), bar_margin_top: theme.geometry.top_bar_margin_top.max(0), + bar_window_gap: theme.geometry.top_bar_window_gap.max(0), bar_margin_horizontal: theme.geometry.top_bar_margin_horizontal.max(0), bar_radius: nonnegative_or(theme.geometry.top_bar_radius, defaults.bar_radius), panel_padding: nonnegative_or(theme.geometry.panel_padding, defaults.panel_padding), control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap), shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y), shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur), - shadow_opacity: theme.shadow.soft.opacity.clamp(0.0, 1.0), + shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity) + .clamp(0.0, 1.0), } } @@ -282,22 +412,25 @@ fn default_shadow() -> String { "#00000055".to_owned() } const fn default_bar_height() -> f32 { - 38.0 + 28.0 +} +const fn default_bar_window_gap() -> i32 { + 8 } const fn default_bar_margin_top() -> i32 { - 4 + 0 } const fn default_bar_margin_horizontal() -> i32 { - 10 + 0 } const fn default_bar_radius() -> f32 { - 19.0 + 0.0 } const fn default_panel_padding() -> f32 { 12.0 } const fn default_control_gap() -> f32 { - 8.0 + 12.0 } const fn default_shadow_offset_y() -> f32 { 4.0 @@ -363,5 +496,47 @@ mod tests { assert_eq!(config.theme.typography.font_family, None); assert_eq!(config.theme.background.path, None); assert_eq!(config.theme.background.mode, WallpaperMode::Fill); + let theme = shell_theme(&config.theme); + assert_eq!(theme.bar_margin_top, 0); + assert_eq!(theme.bar_height, 28.0); + assert_eq!(theme.bar_margin_horizontal, 0); + assert_eq!(theme.bar_radius, 0.0); + assert_eq!(theme.bar_background, ShellTheme::default().bar_background); + assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary); + assert_ne!(theme.for_bar().text_primary, theme.text_primary); + } + + #[test] + fn bar_palette_can_be_changed_without_changing_popovers() { + let config: FereseConfig = toml::from_str( + r##" + [theme.surface.bar] + background = "#EAECEEDD" + text_primary = "#222222" + text_muted = "#666666" + "##, + ) + .unwrap(); + let theme = shell_theme(&config.theme); + assert_eq!(theme.bar_background, [234, 236, 238, 221]); + assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]); + assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]); + assert_eq!(theme.text_primary, ShellTheme::default().text_primary); + } + + #[test] + fn bar_transparency_and_shadow_opacity_are_independent() { + let config: FereseConfig = toml::from_str( + r#" + [theme.surface.bar] + opacity = 0.6 + [theme.shadow.soft] + opacity = 0.07 + "#, + ) + .unwrap(); + let theme = shell_theme(&config.theme); + assert_eq!(theme.bar_background[3], 153); + assert_eq!(theme.shadow_opacity, 0.07); } } diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 414d0f6..a7ad319 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -140,8 +140,11 @@ fn control_connection() -> Result> { // SAFETY: Ferese passes ownership of this inherited descriptor to the // shell. This function is called once and consumes the descriptor. let socket = unsafe { UnixStream::from_raw_fd(fd) }; - - Ok(Connection::from_socket(socket)?) + // Rust's cloned stream is close-on-exec. Do not leak shell-control authority + // into status helpers or applications launched from the menu. + let connection_socket = socket.try_clone()?; + drop(socket); + Ok(Connection::from_socket(connection_socket)?) } struct ControlState { diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 4d8def1..f54e96d 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1,7 +1,16 @@ mod config; mod control; +mod motion; +mod status; +mod status_ui; -use std::time::Duration; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +// Older libcosmic backends expose native surfaces in frame events. Keep +// that compatibility path gated; current backends use Window::Opened and +// window::run to retrieve handles without updates at the display refresh rate. +static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true); use cosmic::Element; use cosmic::app::{Core, Settings, Task}; @@ -39,6 +48,7 @@ struct BarMetrics { height: f32, text_size: u16, icon_size: u16, + overview_icon_size: u16, control_height: f32, workspace_hit_width: f32, active_marker_width: f32, @@ -53,14 +63,17 @@ impl From for BarMetrics { Self { height, - text_size: (height * 0.368).round() as u16, - icon_size: (height * 0.553).round() as u16, - control_height: height * 0.79, - workspace_hit_width: height * 0.68, - active_marker_width: height * 0.47, - active_marker_height: height * 0.13, - dot_marker_size: height * 0.18, - empty_marker_size: height * 0.13, + // Logical sizes: Wayland/iced applies each surface's output scale. + // Compacting the bar must not also shrink its readable content. + text_size: 13, + icon_size: 19, + overview_icon_size: 19, + control_height: (height - 4.0).max(21.0).min(height), + workspace_hit_width: 22.0, + active_marker_width: 14.0, + active_marker_height: 4.0, + dot_marker_size: 4.0, + empty_marker_size: 3.0, } } } @@ -90,14 +103,28 @@ struct FereseShell { overview_active: bool, clock: String, effects: Option, + status_service: status::Service, + status: status::Snapshot, + status_error: Option, + menu: Option, } #[derive(Clone, Debug)] enum Message { Event(Event, window::Id), + NativeSurface( + window::Id, + Result<(Connection, wl_surface::WlSurface), String>, + ), Tick, ActivateWorkspace(u64), ToggleOverview, + StatusTick, + AnimateMenu, + OpenMenu(status_ui::Menu, cosmic::iced::Rectangle), + Control(status::Action), + ConfirmPower(status::Action), + CancelPower, } impl cosmic::Application for FereseShell { @@ -123,6 +150,10 @@ impl cosmic::Application for FereseShell { let app = Self { core, bar_surface_id, + status_service: status::Service::start(config.status.settings_command.clone()), + status: status::Snapshot::default(), + status_error: None, + menu: None, config, control: ShellControl::connect() .map_err(|error| { @@ -167,7 +198,10 @@ impl cosmic::Application for FereseShell { }, size: Some((None, Some(bar.height.round() as u32))), size_limits: Limits::NONE, - exclusive_zone: bar.height.round() as i32, + // Reserve breathing room below the visible bar; layer-shell + // accounts for the top margin separately. + exclusive_zone: (bar.height.round() as i32) + .saturating_add(shell_theme.bar_window_gap), ..Default::default() }, Some(Box::new(Self::view_layer)), @@ -182,13 +216,92 @@ impl cosmic::Application for FereseShell { fn subscription(&self) -> Subscription { Subscription::batch([ - event::listen_with(|event, _status, id| Some(Message::Event(event, id))), - cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::Tick), + event::listen_with(|event, _status, id| match &event { + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => { + EFFECT_FRAME_PENDING + .load(Ordering::Relaxed) + .then_some(Message::Event(event, id)) + } + Event::Keyboard(_) + | Event::Window(window::Event::Opened { .. } | window::Event::Closed) + | Event::PlatformSpecific(PlatformSpecific::Wayland( + wayland::Event::Popup(..) | wayland::Event::Layer(..), + )) => Some(Message::Event(event, id)), + _ => None, + }), + cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick), + cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::StatusTick), + if self.menu.as_ref().is_some_and(|menu| menu.animating()) { + cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu) + } else { + Subscription::none() + }, ]) } fn update(&mut self, message: Self::Message) -> Task { match message { + Message::NativeSurface(id, result) => { + match result { + Ok((_connection, surface)) if id == self.bar_surface_id => { + self.attach_effects(&surface) + } + Ok((_connection, surface)) => { + if let Some(menu) = &mut self.menu + && menu.id == id + && menu.effects.is_none() + { + match EffectsBinding::attach_role( + &surface, + Some(ferese_surface_effects_v1::Role::Popover), + ) { + Ok(binding) => menu.effects = Some(binding), + Err(error) => { + eprintln!("ferese-shell: popover material unavailable: {error}") + } + } + } + } + Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"), + } + Task::none() + } + Message::StatusTick => { + if let Some(update) = self.status_service.poll() + && update.generation >= self.status_service.generation + { + self.status = update.snapshot; + self.status_error = update.error; + } + Task::none() + } + Message::AnimateMenu => Task::none(), + Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor), + Message::ConfirmPower(action) => { + if let Some(menu) = &mut self.menu { + menu.confirm = Some(action); + } + Task::none() + } + Message::CancelPower => { + if let Some(menu) = &mut self.menu { + menu.confirm = None; + } + Task::none() + } + Message::Control(action) => { + self.status_error = self.status_service.send(action.clone()).err(); + if self.status_error.is_none() { + self.optimistic_status(&action); + } + if matches!( + action, + status::Action::Notifications | status::Action::Settings + ) { + return self.destroy_menu(); + } + Task::none() + } Message::Event(event, id) => self.handle_event(event, id), Message::Tick => { self.clock = current_time(); @@ -216,7 +329,10 @@ impl cosmic::Application for FereseShell { let input_zone = if hidden { Some(Vec::new()) } else { None }; - return set_input_zone(self.bar_surface_id, input_zone); + return Task::batch([ + set_input_zone(self.bar_surface_id, input_zone), + self.destroy_menu(), + ]); } } } @@ -238,6 +354,12 @@ impl cosmic::Application for FereseShell { } } + fn style(&self) -> Option { + // Shell surfaces own their backgrounds. Never inherit an opaque + // application clear color during startup or a system-theme change. + Some(shell_surface_style(self.config.theme)) + } + fn view(&self) -> Element<'_, Self::Message> { text("").into() } @@ -250,9 +372,94 @@ impl cosmic::Application for FereseShell { impl FereseShell { fn handle_event(&mut self, event: Event, id: window::Id) -> Task { match event { + Event::Window(window::Event::Opened { .. }) + if id == self.bar_surface_id + || self.menu.as_ref().is_some_and(|menu| menu.id == id) => + { + window::run(id, native_wayland_surface) + .map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))) + } + Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), + .. + }) if self.menu.is_some() => self.destroy_menu(), + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup( + event, + surface, + popup_id, + ))) => { + if let Some(menu) = &mut self.menu + && menu.id == popup_id + { + match event { + wayland::PopupEvent::Done => { + self.menu = None; + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + } + wayland::PopupEvent::Focused => { + if menu.effects.is_none() { + match EffectsBinding::attach_role( + &surface, + Some(ferese_surface_effects_v1::Role::Popover), + ) { + Ok(binding) => menu.effects = Some(binding), + Err(error) => eprintln!( + "ferese-shell: popover material unavailable: {error}" + ), + } + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + } + } + wayland::PopupEvent::Configured { .. } => { + if menu.effects.is_none() { + match EffectsBinding::attach_role( + &surface, + Some(ferese_surface_effects_v1::Role::Popover), + ) { + Ok(binding) => menu.effects = Some(binding), + Err(error) => eprintln!( + "ferese-shell: popover material unavailable: {error}" + ), + } + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + } + } + _ => {} + } + } + Task::none() + } + Event::Window(window::Event::Closed) + if self.menu.as_ref().is_some_and(|menu| menu.id == id) => + { + self.menu = None; + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + Task::none() + } Event::Window(window::Event::Closed) if id == self.bar_surface_id => { cosmic::iced::exit() } + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( + _, + surface, + frame_id, + ))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => { + if let Some(menu) = &mut self.menu { + if menu.effects.is_none() { + match EffectsBinding::attach_role( + &surface, + Some(ferese_surface_effects_v1::Role::Popover), + ) { + Ok(binding) => menu.effects = Some(binding), + Err(error) => { + eprintln!("ferese-shell: popover material unavailable: {error}") + } + } + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + } + } + Task::none() + } Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame( _, surface, @@ -278,6 +485,8 @@ impl FereseShell { return; } + EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); + match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) { Ok(binding) => self.effects = Some(binding), Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"), @@ -299,24 +508,28 @@ impl FereseShell { .find(|output| output.focused) .or_else(|| self.snapshot.outputs.first()); let focused_output_id = focused_output.map(|output| output.id); - let shell_theme = self.config.theme; + let shell_theme = self.config.theme.for_bar(); let bar = BarMetrics::from(shell_theme); let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) .spacing(1) .align_y(cosmic::iced::Alignment::Center); workspace_row = workspace_row.push( - button::custom(overview_control(bar, shell_theme)) + button::custom(overview_control(bar, color(shell_theme.text_primary))) .height(bar.control_height) - .padding(0) - .class(theme::Button::Transparent) + .padding([0, 7]) + .class(status_ui::button_style( + color(shell_theme.text_primary), + self.overview_active, + 1.0, + )) .on_press(cosmic::Action::App(Message::ToggleOverview)), ); for workspace in self .snapshot .workspaces .iter() - .filter(|workspace| workspace.output == focused_output_id) + .filter(|workspace| self.overview_active && workspace.output == focused_output_id) { let occupied = self .snapshot @@ -327,7 +540,11 @@ impl FereseShell { let workspace_button = button::custom(indicator) .height(bar.control_height) .padding(0) - .class(theme::Button::Transparent) + .class(status_ui::button_style( + color(shell_theme.text_primary), + false, + 1.0, + )) .on_press(cosmic::Action::App(Message::ActivateWorkspace( workspace.id, ))); @@ -346,36 +563,49 @@ impl FereseShell { }) .map(display_app_name) .unwrap_or_else(|| "Desktop".to_owned()); - let left = row![workspace_row, text(app_name).size(bar.text_size)] - .spacing(shell_theme.control_gap) - .align_y(cosmic::iced::Alignment::Center); - let center = text(&self.clock).size(bar.text_size); - let right = row![ - symbolic_icon(include_bytes!("../assets/icons/wifi.svg"), bar.icon_size), - symbolic_icon(include_bytes!("../assets/icons/volume.svg"), bar.icon_size), - symbolic_icon(include_bytes!("../assets/icons/battery.svg"), bar.icon_size), + let foreground = color(shell_theme.text_primary); + let app_name = if self.overview_active { + "Overview".to_owned() + } else { + app_name + }; + let left = row![ + workspace_row, + text(app_name) + .size(bar.text_size) + .font(cosmic::font::semibold()) + .width(Length::Fill) + .wrapping(cosmic::iced::core::text::Wrapping::None) + .class(theme::Text::Color(foreground)) ] - .spacing(shell_theme.control_gap) + .spacing(8) .align_y(cosmic::iced::Alignment::Center); + let clock = container( + text(&self.clock) + .size(bar.text_size) + .wrapping(cosmic::iced::core::text::Wrapping::None) + .class(theme::Text::Color(foreground)), + ) + .padding([0, 6]); + let right = row![self.view_status_bar(), clock] + .spacing(10) + .align_y(cosmic::iced::Alignment::Center); let content = row![ - container(left) - .width(Length::FillPortion(1)) - .align_x(alignment::Horizontal::Left), - container(center) - .width(Length::FillPortion(1)) - .align_x(alignment::Horizontal::Center), - container(right) - .width(Length::FillPortion(1)) - .align_x(alignment::Horizontal::Right), + container(left).width(Length::Fill), + container(right).width(Length::Shrink), ] + .spacing(16) .align_y(cosmic::iced::Alignment::Center) .height(Length::Fill); + let compositor_material = self.effects.is_some(); container(content) .width(Length::Fill) .height(Length::Fill) .padding([0, shell_theme.panel_padding.round() as u16]) - .class(theme::Container::custom(move |_| bar_style(shell_theme))) + .class(theme::Container::custom(move |_| { + bar_style(shell_theme, compositor_material) + })) .into() } @@ -392,11 +622,16 @@ impl FereseShell { WallpaperMode::Fit => ContentFit::Contain, }; - image(path.clone()) - .width(Length::Fill) - .height(Length::Fill) - .content_fit(content_fit) - .into() + container( + image(path.clone()) + .width(Length::Fill) + .height(Length::Fill) + .content_fit(content_fit), + ) + .width(Length::Fill) + .height(Length::Fill) + .class(theme::Container::custom(wallpaper_fallback_style)) + .into() } } @@ -407,29 +642,29 @@ fn symbolic_icon(source: &'static [u8], size: u16) -> icon::Icon { .size(size) } +fn bar_icon(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon { + // COSMIC's transparent containers replace inherited foreground colors. + // Pin the bar glyph color rather than relying on those container defaults. + symbolic_icon(source, size).class(theme::Svg::custom(move |_| { + cosmic::iced::widget::svg::Style { + color: Some(foreground), + } + })) +} + fn overview_control( bar: BarMetrics, - shell_theme: ShellTheme, + foreground: Color, ) -> Element<'static, cosmic::Action> { - container(symbolic_icon( - include_bytes!("../assets/icons/overview.svg"), - bar.icon_size, + container(bar_icon( + include_bytes!("../assets/icons/ferese.svg"), + bar.overview_icon_size, + foreground, )) - .width(bar.control_height) + .width(bar.overview_icon_size) .height(bar.control_height) .align_x(alignment::Horizontal::Center) .align_y(alignment::Vertical::Center) - .class(theme::Container::custom(move |_| container::Style { - icon_color: Some(color(shell_theme.text_primary)), - background: Some(Background::Color(color(shell_theme.accent))), - border: Border { - color: color_with_opacity(shell_theme.text_primary, 0.14), - width: 1.0, - radius: (bar.control_height * 0.34).into(), - }, - snap: true, - ..container::Style::default() - })) .into() } @@ -523,28 +758,34 @@ fn display_app_name(window: &control::WindowSnapshot) -> String { } fn current_time() -> String { - Zoned::now().strftime("%H:%M").to_string() + Zoned::now().strftime("%a · %H:%M").to_string() } -fn bar_style(theme: ShellTheme) -> container::Style { - let mut shadow = color(theme.shadow); - shadow.a *= theme.shadow_opacity; +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), + } +} +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)), - background: None, - border: Border { - color: color(theme.border), - width: 1.0, - radius: theme.bar_radius.into(), + // 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))) }, - shadow: Shadow { - color: shadow, - offset: Vector::new(0.0, theme.shadow_offset_y), - blur_radius: theme.shadow_blur, + border: Border { + radius: theme.bar_radius.into(), + ..Border::default() }, snap: true, + ..container::Style::default() } } @@ -566,6 +807,39 @@ fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style { } } +// Resolve a surface while iced guarantees the native handles are alive. Keep +// the borrowed-display connection alive until the effects binding takes over. +fn native_wayland_surface( + window: &dyn cosmic::iced::window::Window, +) -> Result<(Connection, wl_surface::WlSurface), String> { + use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle}; + let display = window.display_handle().map_err(|error| error.to_string())?; + let handle = window.window_handle().map_err(|error| error.to_string())?; + let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) = + (display.as_raw(), handle.as_raw()) + else { + return Err("shell material requires a Wayland surface".to_owned()); + }; + // SAFETY: iced lends matching, live Wayland handles for this callback. + // This backend borrows the display; it never disconnects iced's connection. + // Both handles belong to the runtime, which outlives the shell bindings. + let backend = unsafe { + wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast()) + }; + let connection = Connection::from_backend(backend); + // SAFETY: raw-window-handle identifies this pointer as a live wl_surface. + 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())?; + Ok((connection, surface)) +} + struct EffectsBinding { connection: Connection, _manager: FereseEffectsManagerV1, @@ -577,6 +851,16 @@ impl EffectsBinding { fn attach( surface: &wl_surface::WlSurface, visible: bool, + ) -> Result> { + Self::attach_role( + surface, + visible.then_some(ferese_surface_effects_v1::Role::Panel), + ) + } + + fn attach_role( + surface: &wl_surface::WlSurface, + role: Option, ) -> Result> { let backend = surface .backend() @@ -588,8 +872,8 @@ impl EffectsBinding { let manager = globals.bind::(&qh, 1..=1, ())?; let effects = manager.get_surface_effects(surface, &qh, ()); - if visible { - effects.set_role(ferese_surface_effects_v1::Role::Panel); + if let Some(role) = role { + effects.set_role(role); } else { effects.clear_role(); } @@ -638,6 +922,64 @@ mod tests { use super::*; use crate::control::{OutputSnapshot, WindowSnapshot}; + #[test] + fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() { + let theme = ShellTheme::default(); + assert_eq!( + shell_surface_style(theme).background_color, + Color::TRANSPARENT + ); + let Some(Background::Color(background)) = bar_style(theme.for_bar(), false).background + else { + panic!("first frame needs a fallback fill before material attachment"); + }; + assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20); + assert!(background.a > 0.9); + assert!( + theme.bar_text_primary[..3] + .iter() + .all(|channel| *channel > 220) + ); + } + + #[test] + fn bar_fallback_uses_the_matching_dark_palette_and_geometry() { + let theme = ShellTheme::default().for_bar(); + let style = bar_style(theme, false); + assert_eq!( + style.background, + Some(Background::Color(color(theme.bar_background))) + ); + assert_eq!(style.text_color, Some(color(theme.bar_text_primary))); + assert_eq!(style.border.radius, theme.bar_radius.into()); + assert_eq!(style.shadow, cosmic::iced::Shadow::default()); + } + + #[test] + fn bar_does_not_cover_the_selected_compositor_material() { + let theme = ShellTheme::default().for_bar(); + let style = bar_style(theme, true); + assert_eq!(style.background, None); + assert_eq!(style.text_color, Some(color(theme.bar_text_primary))); + assert_eq!(style.border.radius, theme.bar_radius.into()); + assert_eq!(style.shadow, cosmic::iced::Shadow::default()); + } + + #[test] + fn compact_bar_preserves_logical_text_and_icon_sizes() { + for height in [24.0, 26.0, 32.0, 38.0, 44.0] { + let metrics = BarMetrics::from(ShellTheme { + bar_height: height, + ..ShellTheme::default() + }); + assert_eq!(metrics.text_size, 13); + assert_eq!(metrics.icon_size, 19); + assert_eq!(metrics.overview_icon_size, 19); + assert!(metrics.control_height >= f32::from(metrics.icon_size)); + assert!(metrics.control_height <= height); + } + } + #[test] fn fullscreen_on_the_active_workspace_hides_the_bar() { let snapshot = snapshot_with_fullscreen_window(7); diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs new file mode 100644 index 0000000..9c67259 --- /dev/null +++ b/shell/ferese-shell/src/motion.rs @@ -0,0 +1,115 @@ +//! Draw-only popup transform; layout stays stable while its presentation animates. +use cosmic::iced::advanced::Renderer as _; +use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget}; +use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector}; +use cosmic::{Element, Theme}; + +pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32) -> Element<'a, M> { + Element::new(Motion { content, progress }) +} +struct Motion<'a, M> { + content: Element<'a, M>, + progress: f32, +} +impl Motion<'_, M> { + fn translation(&self) -> Vector { + Vector::new(0.0, -4.0 * (1.0 - self.progress)) + } + fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor { + cursor.position().map_or(mouse::Cursor::Unavailable, |p| { + mouse::Cursor::Available(p - self.translation()) + }) + } +} +impl Widget for Motion<'_, M> { + fn tag(&self) -> widget::tree::Tag { + self.content.as_widget().tag() + } + fn state(&self) -> widget::tree::State { + self.content.as_widget().state() + } + fn children(&self) -> Vec { + self.content.as_widget().children() + } + fn diff(&mut self, tree: &mut widget::Tree) { + self.content.as_widget_mut().diff(tree); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &cosmic::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content.as_widget_mut().layout(tree, renderer, limits) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &cosmic::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, M>, + viewport: &Rectangle, + ) { + let cursor = self.cursor(cursor); + self.content.as_widget_mut().update( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut cosmic::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let v = self.translation(); + let transform = Transformation::translate(v.x, v.y); + renderer.with_transformation(transform, |renderer| { + self.content.as_widget().draw( + tree, + renderer, + theme, + style, + layout, + self.cursor(cursor), + viewport, + ) + }); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &cosmic::Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + tree, + layout, + self.cursor(cursor), + viewport, + renderer, + ) + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &cosmic::Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(tree, layout, renderer, operation); + } +} diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs new file mode 100644 index 0000000..fb987f8 --- /dev/null +++ b/shell/ferese-shell/src/status.rs @@ -0,0 +1,463 @@ +//! Bounded, off-UI-thread adapters. A missing service is `None`, never a fake state. +use std::{ + env, fs, + path::Path, + process::{Command, Stdio}, + sync::{ + Arc, Condvar, Mutex, + mpsc::{self, Receiver, SyncSender}, + }, + thread, + time::Duration, +}; + +#[derive(Clone, Debug, Default)] +pub struct Snapshot { + pub network: Option, + pub bluetooth: Option, + pub audio: Option