From bbc5b27c25c62d964421dbe50dd0d96d1ef65ea7 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Wed, 7 Oct 2026 07:00:30 +0100 Subject: [PATCH] Add unified window focus opacity and dimming effects --- compositor/src/config.rs | 50 +++-- compositor/src/config/appearance.rs | 41 +++-- compositor/src/dimming.rs | 174 ------------------ compositor/src/focus_effect.rs | 155 ++++++++++++++++ compositor/src/input.rs | 2 +- compositor/src/main.rs | 2 +- compositor/src/overview.rs | 2 + compositor/src/presentation.rs | 32 +++- compositor/src/reload.rs | 45 +++-- compositor/src/render/closing.rs | 3 +- compositor/src/render/scene.rs | 29 +-- compositor/src/render/window_content.rs | 20 +- compositor/src/state.rs | 12 +- compositor/src/state/animation.rs | 84 ++++----- compositor/src/state/capture_privacy_tests.rs | 1 - compositor/src/state/lifecycle.rs | 2 - compositor/src/state/prediction.rs | 169 ++++++++++++++--- compositor/src/state/window_registry.rs | 2 +- docs/configuration.md | 14 +- packaging/config.kdl | 8 +- shell/ferese-settings/src/schema.rs | 48 +++-- 21 files changed, 553 insertions(+), 342 deletions(-) delete mode 100644 compositor/src/dimming.rs create mode 100644 compositor/src/focus_effect.rs diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 859a6ac..165f801 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup}; use appearance::*; -pub use appearance::{BorderGradient, InactiveDimSettings, MaterialStyle, ThemeSettings}; +pub use appearance::{BorderGradient, FocusEffectSettings, MaterialStyle, ThemeSettings}; use bindings::*; pub use bindings::{Binding, BindingAction}; pub(crate) use bindings::{BindingSet, physical_keymap}; @@ -137,7 +137,7 @@ impl Config { bindings, window_rules: self.window_rules()?, theme_settings: self.theme_settings()?, - inactive_dim: self.inactive_dim_settings()?, + focus_effect: self.focus_effect_settings()?, default_column_width: self.default_column_width()?, scrolling_focus_strategy: self.scrolling_focus_strategy(), column_width_presets: self.width_presets()?, @@ -880,31 +880,43 @@ mod tests { } #[test] - fn inactive_dimming_is_opt_in_and_configurable() { - let defaults = parse("").inactive_dim_settings().unwrap(); - assert!(!defaults.enabled); - assert_eq!(defaults.amount, 0.15); + fn focus_effect_defaults_and_validation() { + let defaults = parse("").focus_effect_settings().unwrap(); + assert!(defaults.enabled); + assert_eq!(defaults.active_opacity, 1.0); + assert_eq!(defaults.inactive_opacity, 1.0); + assert_eq!(defaults.inactive_dim, 0.0); assert_eq!(defaults.duration_ms, 150.0); - let settings = - parse("appearance {\n inactive-dim {\n enabled #true\n amount 0.25\n duration-ms 100\n }\n}\n") - .inactive_dim_settings() - .unwrap(); + let settings = parse("appearance { focus-effect { active-opacity 0.9; inactive-opacity 0.8; inactive-dim 0.25; duration-ms 100; }; }") + .focus_effect_settings().unwrap(); assert!(settings.enabled); - assert_eq!(settings.amount, 0.25); + assert_eq!(settings.active_opacity, 0.9); + assert_eq!(settings.inactive_opacity, 0.8); + assert_eq!(settings.inactive_dim, 0.25); assert_eq!(settings.duration_ms, 100.0); - for (key, value) in [ - ("amount", "-0.1"), - ("amount", "1.1"), - ("amount", "#nan"), - ("duration_ms", "-1"), - ("duration_ms", "#inf"), - ] { + for key in ["active-opacity", "inactive-opacity", "inactive-dim"] { + for value in ["-0.1", "1.1", "#nan", "#inf"] { + assert!( + Config::parse_source(&format!("appearance {{ focus-effect {{ {key} {value}; }} }}")) + .and_then(|config| config.runtime_config()) + .is_err() + ); + } + } + for value in ["-1", "#nan", "#inf"] { assert!( - Config::parse_source(&format!("appearance {{ inactive-dim {{ {key} {value}; }} }}")) + Config::parse_source(&format!("appearance {{ focus-effect {{ duration-ms {value}; }} }}")) .and_then(|config| config.runtime_config()) .is_err() ); } + let disabled = parse("appearance { focus-effect { enabled #false; active-opacity 0.9; inactive-opacity 0.8; inactive-dim 0.25; }; }") + .focus_effect_settings().unwrap(); + assert!(!disabled.enabled); + assert_eq!(disabled.active_opacity, 0.9); + assert_eq!(disabled.inactive_opacity, 0.8); + assert_eq!(disabled.inactive_dim, 0.25); + assert!(Config::parse_source("appearance { inactive-dim { enabled #true; }; }").is_err()); } #[test] diff --git a/compositor/src/config/appearance.rs b/compositor/src/config/appearance.rs index ee1a09c..6b361b6 100644 --- a/compositor/src/config/appearance.rs +++ b/compositor/src/config/appearance.rs @@ -1,14 +1,14 @@ use super::*; impl Config { - pub(crate) fn inactive_dim_settings(&self) -> Result { - Ok(InactiveDimSettings { - enabled: self.appearance.inactive_dim.enabled, - amount: unit_theme_value(self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount")?, - duration_ms: nonnegative_theme_value( - self.appearance.inactive_dim.duration_ms, - "appearance.inactive_dim.duration_ms", - )?, + pub(crate) fn focus_effect_settings(&self) -> Result { + let effect = &self.appearance.focus_effect; + Ok(FocusEffectSettings { + enabled: effect.enabled, + active_opacity: unit_theme_value(effect.active_opacity, "appearance.focus_effect.active_opacity")?, + inactive_opacity: unit_theme_value(effect.inactive_opacity, "appearance.focus_effect.inactive_opacity")?, + inactive_dim: unit_theme_value(effect.inactive_dim, "appearance.focus_effect.inactive_dim")?, + duration_ms: nonnegative_theme_value(effect.duration_ms, "appearance.focus_effect.duration_ms")?, }) } @@ -242,32 +242,39 @@ pub enum MaterialStyle { } #[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub(super) struct AppearanceConfig { pub(super) corner_radius: Option, #[serde(default)] - pub(super) inactive_dim: InactiveDimConfig, + pub(super) focus_effect: FocusEffectConfig, } #[derive(Clone, Copy, Debug, PartialEq)] -pub struct InactiveDimSettings { +pub struct FocusEffectSettings { pub enabled: bool, - pub amount: f64, + pub active_opacity: f64, + pub inactive_opacity: f64, + pub inactive_dim: f64, pub duration_ms: f64, } #[derive(Debug, Deserialize)] -#[serde(default)] -pub(super) struct InactiveDimConfig { +#[serde(default, deny_unknown_fields)] +pub(super) struct FocusEffectConfig { pub(super) enabled: bool, - pub(super) amount: f64, + pub(super) active_opacity: f64, + pub(super) inactive_opacity: f64, + pub(super) inactive_dim: f64, pub(super) duration_ms: f64, } -impl Default for InactiveDimConfig { +impl Default for FocusEffectConfig { fn default() -> Self { Self { - enabled: false, - amount: 0.15, + enabled: true, + active_opacity: 1.0, + inactive_opacity: 1.0, + inactive_dim: 0.0, duration_ms: 150.0, } } diff --git a/compositor/src/dimming.rs b/compositor/src/dimming.rs deleted file mode 100644 index e99b807..0000000 --- a/compositor/src/dimming.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::time::Duration; - -use ferese_layout::WindowId; - -use crate::config::InactiveDimSettings; - -pub(crate) fn target( - settings: InactiveDimSettings, - focused: Option, - window: WindowId, - overview: bool, -) -> f64 { - if settings.enabled && !overview && focused.is_some() && focused != Some(window) { - settings.amount - } else { - 0.0 - } -} - -/// A bounded, interruption-safe opacity transition; it never overshoots. -#[derive(Clone, Debug)] -pub(crate) struct DimAnimation { - pub current: f64, - start: f64, - target: f64, - elapsed: f64, -} - -impl DimAnimation { - pub(crate) fn needs_update(&self, target: f64) -> bool { - self.target != target || self.current != target - } - - pub(crate) fn is_animating(&self) -> bool { - self.current != self.target - } - - pub(crate) fn predict(&mut self, delta: Duration, duration_ms: f64) { - self.advance(self.target, delta, duration_ms); - } - - pub fn new(value: f64) -> Self { - Self { - current: value, - start: value, - target: value, - elapsed: 0.0, - } - } - - pub fn advance(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool { - if self.target != target { - self.start = self.current; - self.target = target; - self.elapsed = 0.0; - } - - if duration_ms == 0.0 { - self.current = target; - return false; - } - - self.elapsed += delta.as_secs_f64() * 1000.0; - let progress = (self.elapsed / duration_ms).min(1.0); - if progress == 1.0 { - self.current = target; - return false; - } - - let eased = progress * progress * (3.0 - 2.0 * progress); - self.current = self.start + (target - self.start) * eased; - self.current != target - } - - /// The target changed at this tick, not at the beginning of a potentially - /// long idle interval. Do not spend that old elapsed time on a new fade. - pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool { - self.advance( - target, - if self.target != target { Duration::ZERO } else { delta }, - duration_ms, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn visual_focus_and_dimming_start_together_after_idle_and_reverse() { - let mut focus = DimAnimation::new(0.0); - let mut dim = DimAnimation::new(0.15); - assert!(focus.advance_visual(1.0, Duration::from_secs(30), 150.0)); - assert!(dim.advance_visual(0.0, Duration::from_secs(30), 150.0)); - assert_eq!(focus.current, 0.0); - assert_eq!(dim.current, 0.15); - focus.advance_visual(1.0, Duration::from_millis(75), 150.0); - dim.advance_visual(0.0, Duration::from_millis(75), 150.0); - assert!((focus.current + dim.current / 0.15 - 1.0).abs() < 1e-9); - let before = focus.current; - focus.advance_visual(0.0, Duration::from_millis(16), 150.0); - assert_eq!(focus.current, before); - assert!(!focus.advance_visual(0.0, Duration::from_millis(150), 150.0)); - assert!(!focus.advance_visual(1.0, Duration::from_secs(30), 0.0)); - assert_eq!(focus.current, 1.0); - } - - #[test] - fn only_unfocused_windows_dim_and_overview_disables_it() { - let settings = InactiveDimSettings { - enabled: true, - amount: 0.15, - duration_ms: 150.0, - }; - let left = WindowId(1); - let right = WindowId(2); - assert_eq!(target(settings, Some(right), left, false), 0.15); - assert_eq!(target(settings, Some(right), right, false), 0.0); - assert_eq!(target(settings, Some(right), left, true), 0.0); - assert_eq!(target(settings, None, left, false), 0.0); - assert_eq!( - target( - InactiveDimSettings { - enabled: false, - ..settings - }, - Some(right), - left, - false - ), - 0.0 - ); - assert_eq!( - target( - InactiveDimSettings { - amount: 0.0, - ..settings - }, - Some(right), - left, - false - ), - 0.0 - ); - } - - #[test] - fn focus_transition_is_smooth_bounded_and_settles() { - let mut dim = DimAnimation::new(0.0); - assert!(dim.advance(0.15, Duration::from_millis(75), 150.0)); - assert!((dim.current - 0.075).abs() < 1e-9); - assert!(!dim.advance(0.15, Duration::from_millis(75), 150.0)); - assert_eq!(dim.current, 0.15); - assert!(!dim.advance(0.15, Duration::from_secs(10), 150.0)); - } - - #[test] - fn reversing_focus_starts_from_current_opacity() { - let mut dim = DimAnimation::new(0.0); - dim.advance(0.15, Duration::from_millis(75), 150.0); - let before = dim.current; - assert!(dim.advance(0.0, Duration::ZERO, 150.0)); - assert_eq!(dim.current, before); - assert!(!dim.advance(0.0, Duration::from_millis(150), 150.0)); - assert_eq!(dim.current, 0.0); - } - - #[test] - fn zero_duration_snaps_for_reduced_motion() { - let mut dim = DimAnimation::new(0.15); - assert!(!dim.advance(0.0, Duration::ZERO, 0.0)); - assert_eq!(dim.current, 0.0); - } -} diff --git a/compositor/src/focus_effect.rs b/compositor/src/focus_effect.rs new file mode 100644 index 0000000..1c0f6b5 --- /dev/null +++ b/compositor/src/focus_effect.rs @@ -0,0 +1,155 @@ +use std::time::Duration; + +use crate::config::FocusEffectSettings; +use ferese_layout::WindowId; + +pub(crate) fn target(settings: FocusEffectSettings, active: Option, window: WindowId) -> f64 { + if !settings.has_effects() || active == Some(window) { + 1.0 + } else { + 0.0 + } +} + +impl FocusEffectSettings { + pub(crate) fn has_effects(self) -> bool { + self.enabled && (self.active_opacity != self.inactive_opacity || self.inactive_dim != 0.0) + } + + pub(crate) fn resolve(self, progress: f64, has_active: bool, overview_opacity: f32) -> (f32, f64) { + if !self.enabled { + return (1.0, 0.0); + } + let progress = progress.clamp(0.0, 1.0); + let alpha = self.inactive_opacity + (self.active_opacity - self.inactive_opacity) * progress; + let dim = if has_active { + self.inactive_dim * (1.0 - progress) * (1.0 - f64::from(overview_opacity)) + } else { + 0.0 + }; + (alpha as f32, dim) + } +} + +/// A bounded, interruption-safe opacity transition; it never overshoots. +#[derive(Clone, Debug)] +pub(crate) struct BoundedFade { + pub current: f64, + start: f64, + target: f64, + elapsed: f64, +} + +impl BoundedFade { + pub(crate) fn needs_update(&self, target: f64) -> bool { + self.target != target || self.current != target + } + + pub(crate) fn is_animating(&self) -> bool { + self.current != self.target + } + + pub fn new(value: f64) -> Self { + Self { + current: value, + start: value, + target: value, + elapsed: 0.0, + } + } + + pub fn advance(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool { + if self.target != target { + self.start = self.current; + self.target = target; + self.elapsed = 0.0; + } + + if duration_ms == 0.0 { + self.current = target; + return false; + } + + self.elapsed += delta.as_secs_f64() * 1000.0; + let progress = (self.elapsed / duration_ms).min(1.0); + if progress == 1.0 { + self.current = target; + return false; + } + + let eased = progress * progress * (3.0 - 2.0 * progress); + self.current = self.start + (target - self.start) * eased; + self.current != target + } + + /// The target changed at this tick, not at the beginning of a potentially + /// long idle interval. Do not spend that old elapsed time on a new fade. + pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool { + self.advance( + target, + if self.target != target { Duration::ZERO } else { delta }, + duration_ms, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings() -> FocusEffectSettings { + FocusEffectSettings { + enabled: true, + active_opacity: 1.0, + inactive_opacity: 0.8, + inactive_dim: 0.2, + duration_ms: 150.0, + } + } + + #[test] + fn disabled_effect_bypasses_opacity_and_dimming_without_changing_settings() { + let mut effect = settings(); + effect.enabled = false; + assert!(!effect.has_effects()); + for progress in [0.0, 0.5, 1.0] { + assert_eq!(effect.resolve(progress, true, 0.0), (1.0, 0.0)); + } + assert_eq!(target(effect, Some(WindowId(1)), WindowId(2)), 1.0); + effect.enabled = true; + assert_eq!(effect.resolve(0.0, true, 0.0), (0.8, 0.2)); + } + + #[test] + fn opacity_and_dimming_share_one_interruption_safe_transition() { + let mut focus = BoundedFade::new(0.0); + assert!(focus.advance_visual(1.0, Duration::from_secs(30), 150.0)); + assert_eq!(focus.current, 0.0); + focus.advance_visual(1.0, Duration::from_millis(75), 150.0); + let (alpha, dim) = settings().resolve(focus.current, true, 0.0); + assert!((alpha - 0.9).abs() < 1e-6); + assert!((dim - 0.1).abs() < 1e-9); + let before = focus.current; + focus.advance_visual(0.0, Duration::from_secs(30), 150.0); + assert_eq!(focus.current, before); + assert!(!focus.advance_visual(0.0, Duration::from_millis(150), 150.0)); + assert_eq!(settings().resolve(focus.current, true, 0.0), (0.8, 0.2)); + } + + #[test] + fn activation_is_independent_of_overview_and_no_active_window() { + assert_eq!(target(settings(), Some(WindowId(1)), WindowId(1)), 1.0); + assert_eq!(target(settings(), Some(WindowId(1)), WindowId(2)), 0.0); + assert_eq!(target(settings(), None, WindowId(2)), 0.0); + assert_eq!(settings().resolve(0.0, true, 1.0), (0.8, 0.0)); + assert_eq!(settings().resolve(0.0, false, 0.0), (0.8, 0.0)); + assert_eq!(settings().resolve(0.0, true, 0.5), (0.8, 0.1)); + } + + #[test] + fn reduced_motion_and_zero_duration_snap() { + let mut focus = BoundedFade::new(0.0); + assert!(!focus.advance_visual(1.0, Duration::ZERO, 0.0)); + assert_eq!(settings().resolve(focus.current, true, 0.0), (1.0, 0.0)); + } +} diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 07fc741..ecaa3ee 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -532,7 +532,7 @@ impl Ferese { self.dismissing_popups.push(( root.clone(), popup, - crate::dimming::DimAnimation::new(f64::from(opacity)), + crate::focus_effect::BoundedFade::new(f64::from(opacity)), )); pointer.unset_grab(self, serial, event.time_msec()); self.focus_window_at(pointer.current_location(), serial, true); diff --git a/compositor/src/main.rs b/compositor/src/main.rs index de3c7cf..efb0a82 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -2,11 +2,11 @@ mod backends; mod config; mod cursor; mod daemon; -mod dimming; mod display_presentation; mod dmabuf_imports; mod effects; mod floating; +mod focus_effect; mod frame_scheduler; mod gestures; mod grabs; diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 32d5a81..7486c97 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -1121,6 +1121,8 @@ mod tests { let scale = 0.97 + 0.03 * opacity.current; let id = WindowId(1); let presentation = WindowPresentation { + focus_alpha: 1.0, + dim: 0.0, id, bounds: AnimatedRect { current: scaled_visual_rect(normal, scale), diff --git a/compositor/src/presentation.rs b/compositor/src/presentation.rs index c70d2a9..9800c9a 100644 --- a/compositor/src/presentation.rs +++ b/compositor/src/presentation.rs @@ -22,8 +22,10 @@ pub(crate) const SNAPSHOT_BUDGET: usize = 64 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct WindowPresentation { pub id: ferese_layout::WindowId, - pub bounds: ferese_animation::AnimatedRect, + pub bounds: AnimatedRect, pub opacity: ferese_animation::AnimatedValue, + pub focus_alpha: f32, + pub dim: f64, pub emphasis: ferese_animation::AnimatedValue, pub shadow: ferese_animation::AnimatedValue, pub scale_content: bool, @@ -32,7 +34,7 @@ pub(crate) struct WindowPresentation { impl WindowPresentation { pub fn alpha(self) -> f32 { - self.opacity.current.clamp(0.0, 1.0) as f32 + self.focus_alpha * self.opacity.current.clamp(0.0, 1.0) as f32 } pub fn focus(self) -> f64 { @@ -41,7 +43,7 @@ impl WindowPresentation { /// A thumbnail is another view of the same content and lifecycle state. pub fn thumbnail(mut self, rect: ferese_layout::Rect) -> Self { - self.bounds = ferese_animation::AnimatedRect::new(rect); + self.bounds = AnimatedRect::new(rect); self.scale_content = true; self.native_size = None; self @@ -362,9 +364,33 @@ impl RenderElement for NativeTextureElement { #[cfg(test)] mod tests { + #[test] + fn activation_alpha_composes_with_lifecycle_without_retargeting_it() { + let mut presentation = moving_presentation(); + presentation.focus_alpha = 0.8; + presentation.opacity.current = 0.5; + let lifecycle = presentation.opacity; + assert!((presentation.alpha() - 0.4).abs() < 1e-6); + presentation.focus_alpha = 0.9; + assert_eq!(presentation.opacity, lifecycle); + assert!((presentation.alpha() - 0.45).abs() < 1e-6); + presentation.close(); + assert_eq!(presentation.focus_alpha, 0.9); + assert_eq!(presentation.opacity.current, 0.5); + assert_eq!(presentation.opacity.target, 0.0); + assert_eq!( + presentation + .thumbnail(ferese_layout::Rect::new(0.0, 0.0, 100.0, 100.0)) + .alpha(), + presentation.alpha() + ); + } + fn moving_presentation() -> WindowPresentation { use ferese_animation::{AnimatedRect, AnimatedValue, RectVelocity}; WindowPresentation { + focus_alpha: 1.0, + dim: 0.0, id: ferese_layout::WindowId(7), bounds: AnimatedRect { velocity: RectVelocity { diff --git a/compositor/src/reload.rs b/compositor/src/reload.rs index c423acc..231485f 100644 --- a/compositor/src/reload.rs +++ b/compositor/src/reload.rs @@ -407,6 +407,7 @@ mod tests { .env_remove("FERESE_SOCKET") .output() .unwrap(); + assert!( result.status.success(), "{}\n{}", @@ -458,7 +459,7 @@ mod tests { } #[test] - fn live_reload_keeps_dimming_policy_independent_of_theme_transitions() { + fn live_reload_keeps_focus_effect_independent_of_theme_transitions() { if std::env::var_os("FERESE_DIM_RELOAD_TEST_CHILD").is_none() { use std::os::unix::fs::PermissionsExt; @@ -467,7 +468,7 @@ mod tests { let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ "--exact", - "reload::tests::live_reload_keeps_dimming_policy_independent_of_theme_transitions", + "reload::tests::live_reload_keeps_focus_effect_independent_of_theme_transitions", "--nocapture", ]) .env("FERESE_DIM_RELOAD_TEST_CHILD", "1") @@ -484,7 +485,7 @@ mod tests { ); return; } - let source = "appearance { inactive-dim { enabled #true; amount 0.25; }; }"; + let source = "appearance { focus-effect { inactive-opacity 0.8; inactive-dim 0.25; }; }"; let directory = Directory::new(); let (_, runtime, candidate) = crate::theme::prepare(source, &directory.0).unwrap(); let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap(); @@ -494,23 +495,37 @@ mod tests { state.theme_engine.live.theme = candidate.theme.clone(); state.theme_engine.live.presented = candidate.theme; - for enabled in [false, true, false] { - let source = format!("appearance {{ inactive-dim {{ enabled #{enabled}; amount 0.25; }}; }}"); + for inactive_opacity in [1.0, 0.75, 0.9] { + let source = + format!("appearance {{ focus-effect {{ inactive-opacity {inactive_opacity}; inactive-dim 0.25; }}; }}"); state.reload_config_source(source).unwrap(); - assert_eq!(state.inactive_dim.enabled, enabled); - let opacity = crate::dimming::target( - state.inactive_dim, - Some(ferese_layout::WindowId(1)), - ferese_layout::WindowId(2), - false, - ); - assert_eq!(opacity, if enabled { 0.25 } else { 0.0 }); + assert_eq!(state.focus_effect.inactive_opacity, inactive_opacity); + let (alpha, dim) = state.focus_effect.resolve(0.0, true, 0.0); + assert_eq!(alpha, inactive_opacity as f32); + assert_eq!(dim, 0.25); } + for enabled in [false, true] { + state.reload_config_source(format!("appearance {{ focus-effect {{ enabled #{enabled}; active-opacity 0.95; inactive-opacity 0.8; inactive-dim 0.25; }}; }}")).unwrap(); + assert_eq!(state.focus_effect.enabled, enabled); + assert_eq!(state.focus_effect.inactive_opacity, 0.8); + assert_eq!(state.focus_effect.inactive_dim, 0.25); + assert_eq!( + state.focus_effect.resolve(0.0, true, 0.0), + if enabled { (0.8, 0.25) } else { (1.0, 0.0) } + ); + } + let previous = state.focus_effect; + assert!( + state + .reload_config_source("appearance { focus-effect { active-opacity 1.1; }; }".into()) + .is_err() + ); + assert_eq!(state.focus_effect, previous); state - .reload_config_source("appearance { inactive-dim { enabled #false; }; }; theme { mode \"light\"; }".into()) + .reload_config_source("appearance { focus-effect { inactive-dim 0; }; }; theme { mode \"light\"; }".into()) .unwrap(); state.poll_theme(); - assert!(!state.inactive_dim.enabled); + assert_eq!(state.focus_effect.inactive_dim, 0.0); } /// The status endpoint must be able to tell "the live service is running diff --git a/compositor/src/render/closing.rs b/compositor/src/render/closing.rs index 95b48ac..010a649 100644 --- a/compositor/src/render/closing.rs +++ b/compositor/src/render/closing.rs @@ -15,7 +15,6 @@ pub(crate) struct ClosedWindow { pub radius: f64, pub shape: CornerShape, pub decorations: f64, - pub dim: f64, pub fill: Option<[f32; 4]>, pub material: Option<( smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, @@ -110,7 +109,7 @@ pub(super) fn grouped_elements( window.presentation.id, constrain, corners, - [0.0, 0.0, 0.0, window.dim as f32 * alpha], + [0.0, 0.0, 0.0, window.presentation.dim as f32 * alpha], false, output, ) { diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index 0a92164..22c0688 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -153,10 +153,17 @@ fn scene_elements( } let sample = frame.windows.get(&id)?; let visual = sample.presentation.bounds.current; - let close_alpha = sample.presentation.alpha(); + let presentation_alpha = sample.presentation.alpha(); let decoration_progress = sample.geometry.decorations.clamp(0.0, 1.0); - Some((window.clone(), id, sample, visual, decoration_progress, close_alpha)) + Some(( + window.clone(), + id, + sample, + visual, + decoration_progress, + presentation_alpha, + )) }; let windows = if state.overview.is_active() { state @@ -180,7 +187,7 @@ fn scene_elements( windows.iter().map(|(_, id, ..)| *id), frame.delta, ); - for (window, id, sample, visual, decoration_progress, close_alpha) in windows { + for (window, id, sample, visual, decoration_progress, presentation_alpha) in windows { if let Some(group) = closing.remove(&Some(id)) { elements.extend(group); } @@ -205,14 +212,14 @@ fn scene_elements( let scale_content = sample.presentation.scale_content; let behavior = resize_content_behavior(scale_content); - let dim = sample.dim; + let dim = sample.presentation.dim; if let Some(overlay) = window_tint_element( &mut state.render, renderer, id, constrain, corners, - [0.0, 0.0, 0.0, dim as f32 * close_alpha], + [0.0, 0.0, 0.0, dim as f32 * presentation_alpha], false, output, ) { @@ -244,7 +251,7 @@ fn scene_elements( border_color, gradient, focus as f32, - close_alpha * decoration_progress as f32, + presentation_alpha * decoration_progress as f32, output, &programs, ) { @@ -260,7 +267,7 @@ fn scene_elements( scale, shadow_offset_y * offset_factor, shadow_blur * blur_factor, - shadow_opacity * opacity_factor * f64::from(close_alpha) * decoration_progress, + shadow_opacity * opacity_factor * f64::from(presentation_alpha) * decoration_progress, shadow_color, output, &programs, @@ -286,7 +293,7 @@ fn scene_elements( &window, corners, scale, - close_alpha, + presentation_alpha, sample.geometry.presentation_changed || corner_shape_changed, output, programs.clone(), @@ -299,7 +306,7 @@ fn scene_elements( && (source.w < constrain.size.w || source.h < constrain.size.h) { let mut color = state.theme_settings.surface_base_color.0; - color[3] = close_alpha; + color[3] = presentation_alpha; if let Some(fill) = window_tint_element(&mut state.render, renderer, id, constrain, corners, color, true, output) { @@ -321,7 +328,7 @@ fn scene_elements( corners, index: 0, capture_geometry: constrain, - alpha: close_alpha, + alpha: presentation_alpha, }, ) { @@ -335,7 +342,7 @@ fn scene_elements( renderer, &window, constrain.loc, - close_alpha, + presentation_alpha, scale, constrain, ConstrainBehavior { diff --git a/compositor/src/render/window_content.rs b/compositor/src/render/window_content.rs index 7efe1dc..81196fb 100644 --- a/compositor/src/render/window_content.rs +++ b/compositor/src/render/window_content.rs @@ -84,8 +84,13 @@ mod tests { ); let mut resources = RenderResources::default(); let programs = corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); - for scale in [1.0, 1.25, 1.5, 2.0] { + for (scale, focus_alpha) in [1.0, 1.25, 1.5, 2.0] + .into_iter() + .flat_map(|scale| [1.0, 0.8].map(|alpha| (scale, alpha))) + { let live = crate::presentation::WindowPresentation { + focus_alpha, + dim: 0.0, id: ferese_layout::WindowId(1), bounds: AnimatedRect::new(ferese_layout::Rect::new(20.25, 20.5, 32.25, 24.25)), opacity: AnimatedValue::new(1.0), @@ -147,7 +152,16 @@ mod tests { pixels.push(renderer.map_texture(&mapping).unwrap().to_vec()); } assert_eq!(pixels[0], pixels[1], "close handoff changed pixels at scale {scale}"); - assert!(pixels[0].chunks_exact(4).any(|pixel| pixel[3] == 255)); + let expected = (focus_alpha * 255.0).round() as u8; + assert!( + pixels[0].chunks_exact(4).any(|pixel| pixel[3].abs_diff(expected) <= 1), + "snapshot alpha must be applied once" + ); + assert!( + pixels[0] + .chunks_exact(4) + .all(|pixel| pixel[3] <= expected.saturating_add(1)) + ); let mut retained = super::super::closing::ClosedWindow { presentation: closing, output: ferese_core::OutputId(1), @@ -157,7 +171,6 @@ mod tests { radius: 8.0, shape: CornerShape::Continuous, decorations: 1.0, - dim: 0.0, fill: None, material: None, }; @@ -166,6 +179,7 @@ mod tests { retained.handoff.as_mut().unwrap().0.elapsed = Duration::from_millis(20); let mut predicted = retained.clone(); predicted.advance(Duration::from_millis(20), Default::default()); + assert_eq!(predicted.presentation.focus_alpha, focus_alpha); let faded = &predicted.handoff.as_ref().unwrap().0; assert_eq!(faded.elapsed, Duration::from_millis(40)); assert_eq!(retained.handoff.as_ref().unwrap().0.elapsed, Duration::from_millis(20)); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index fd30da1..999ae38 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -83,7 +83,7 @@ use window_registry::WindowRegistry; use crate::backends::direct::DirectBackendState; use crate::config::{Binding, BindingSet, DaemonConfig, InputSettings, OutputProfile, ThemeSettings}; use crate::cursor::NamedCursor; -use crate::dimming::DimAnimation; +use crate::focus_effect::BoundedFade; use crate::gestures::{Swipe, SwipeDirection}; use crate::handlers::screencopy::PendingScreencopy; use crate::handlers::screenshot::{Coordinator, PartSender}; @@ -460,7 +460,7 @@ pub struct Ferese { workspace_auto_back_and_forth: bool, pub(crate) window_rules: Vec, pub(crate) theme_settings: ThemeSettings, - pub(crate) inactive_dim: crate::config::InactiveDimSettings, + pub(crate) focus_effect: crate::config::FocusEffectSettings, animations_enabled: bool, animation_speed: f64, pub(crate) spring_config: SpringConfig, @@ -505,7 +505,7 @@ pub struct Ferese { #[cfg(test)] animation_test_time: Option, pub popups: PopupManager, - pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, DimAnimation)>, + pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, BoundedFade)>, pub seat: Seat, pub alpha_modifier_state: AlphaModifierState, pub compositor_state: CompositorState, @@ -551,7 +551,7 @@ pub struct RuntimeConfig { pub workspace_auto_back_and_forth: bool, pub window_rules: Vec, pub theme_settings: ThemeSettings, - pub inactive_dim: crate::config::InactiveDimSettings, + pub focus_effect: crate::config::FocusEffectSettings, pub default_column_width: ColumnWidth, pub scrolling_focus_strategy: ViewportFocusStrategy, pub column_width_presets: Vec, @@ -717,7 +717,7 @@ impl Ferese { workspace_auto_back_and_forth: config.workspace_auto_back_and_forth, window_rules: config.window_rules, theme_settings: config.theme_settings, - inactive_dim: config.inactive_dim, + focus_effect: config.focus_effect, animations_enabled: config.animations_enabled, animation_speed: config.animation_speed, spring_config: config.spring_config, @@ -905,7 +905,7 @@ impl Ferese { let idle_policy_changed = self.idle_inhibit != config.idle_inhibit || old_rules != self.window_rules; self.idle_inhibit = config.idle_inhibit; self.theme_settings = config.theme_settings; - self.inactive_dim = config.inactive_dim; + self.focus_effect = config.focus_effect; self.column_width_presets = config.column_width_presets; self.animations_enabled = config.animations_enabled; if !self.animations_enabled { diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index 421a1d6..ce594b7 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -125,14 +125,9 @@ impl Ferese { self.windows.records().any(|(&id, record)| { let focus = if selected == Some(id) { 1.0 } else { 0.0 }; - let dim = crate::dimming::target( - self.inactive_dim, - self.focused_window, - id, - self.overview.is_presenting(), - ); + let activation = crate::focus_effect::target(self.focus_effect, self.focused_window, id); - record_needs_tick(record, focus, dim, || { + record_needs_tick(record, focus, activation, || { self.windows .window(id) .is_some_and(|window| self.space.element_location(window).is_some()) @@ -196,14 +191,14 @@ impl Ferese { active }); - let dim_settings = self.inactive_dim; - let duration = if self.animations_enabled { - dim_settings.duration_ms + let effect = self.focus_effect; + let duration = if self.animations_enabled && effect.enabled { + effect.duration_ms } else { 0.0 }; - let mut dim_changed = false; + let mut appearance_changed = false; // Include hidden workspace windows: overview can present them too. // Read selection once so these immutable fields can be borrowed alongside // each record without allocating a temporary window-ID vector. @@ -228,7 +223,7 @@ impl Ferese { } else { focus.snap(); } - dim_changed |= previous != focus.current; + appearance_changed |= previous != focus.current; let shadow = record.shadow.get_or_insert_with(|| AnimatedValue::new(target)); let previous = shadow.current; let changed = shadow.target != target; @@ -241,24 +236,15 @@ impl Ferese { } else { shadow.snap(); } - dim_changed |= previous != shadow.current; - } + appearance_changed |= previous != shadow.current; - for window in self.space.elements() { - let Some(id) = self.windows.ids().get(window).copied() else { - continue; - }; - let target = crate::dimming::target(dim_settings, self.focused_window, id, self.overview.is_presenting()); - let Some(record) = self.windows.record_mut(id) else { - continue; - }; - - let dim = record - .dimming - .get_or_insert_with(|| crate::dimming::DimAnimation::new(target)); - let previous = dim.current; - active_animation |= dim.advance_visual(target, delta, duration); - dim_changed |= previous != dim.current; + let target = crate::focus_effect::target(effect, self.focused_window, id); + let transition = record + .focus_transition + .get_or_insert_with(|| crate::focus_effect::BoundedFade::new(target)); + let previous = transition.current; + active_animation |= transition.advance_visual(target, delta, duration); + appearance_changed |= previous != transition.current; } for (_, record) in self.windows.records_mut() { @@ -540,7 +526,7 @@ impl Ferese { self.refresh_workspace_slide_offsets(); self.sync_window_stacking(); - active_animation |= dim_changed; + active_animation |= appearance_changed; active_animation } @@ -640,7 +626,7 @@ pub(super) fn sync_scrolling_coordinates( fn record_needs_tick( record: &super::window_registry::WindowRecord, focus: f64, - dim: f64, + activation: f64, mapped: impl FnOnce() -> bool, ) -> bool { if record.resize.is_some() @@ -652,7 +638,10 @@ fn record_needs_tick( } let moving = |value: &AnimatedValue| value.current != value.target || value.velocity != 0.0; - let pending = record.dimming.as_ref().is_none_or(|motion| motion.needs_update(dim)) + let pending = record + .focus_transition + .as_ref() + .is_none_or(|motion| motion.needs_update(activation)) || record.world_x.as_ref().is_some_and(|(_, world)| moving(world)) || record.coupled_width.is_some() || record.geometry.is_some_and(|geometry| { @@ -665,7 +654,6 @@ fn record_needs_tick( && geometry.client.last_configured_size != Some(ClientSize::from_rect(geometry.logical))) }); - // Hidden geometry and dimming do not advance in the full path. // A stale hidden target must not keep waking the whole desktop. pending && mapped() } @@ -684,24 +672,24 @@ mod tests { geometry: Some(geometry), focus: Some(AnimatedValue::new(1.0)), shadow: Some(AnimatedValue::new(1.0)), - dimming: Some(DimAnimation::new(0.0)), + focus_transition: Some(BoundedFade::new(1.0)), ..Default::default() } } #[test] - fn idle_check_skips_mapping_lookup_but_new_focus_and_dim_targets_wake_it() { + fn idle_check_skips_mapping_lookup_but_new_focus_and_activation_targets_wake_it() { let record = settled(); for _ in 0..100 { - assert!(!record_needs_tick(&record, 1.0, 0.0, || panic!( + assert!(!record_needs_tick(&record, 1.0, 1.0, || panic!( "settled record needs no mapping lookup" ))); } - assert!(record_needs_tick(&record, 0.0, 0.0, || true)); - assert!(record_needs_tick(&record, 1.0, 0.15, || true)); - assert!(!record_needs_tick(&record, 1.0, 0.15, || false)); + assert!(record_needs_tick(&record, 0.0, 1.0, || true)); + assert!(record_needs_tick(&record, 1.0, 0.0, || true)); + assert!(!record_needs_tick(&record, 1.0, 0.0, || false)); } #[test] @@ -710,12 +698,12 @@ mod tests { let shadow = record.shadow.as_mut().unwrap(); shadow.current = 0.9; shadow.velocity = 0.2; - assert!(record_needs_tick(&record, 1.0, 0.0, || false)); + assert!(record_needs_tick(&record, 1.0, 1.0, || false)); record.shadow.as_mut().unwrap().advance( Duration::from_secs(3), crate::presentation::shadow_spring(SpringConfig::default()), ); - assert!(!record_needs_tick(&record, 1.0, 0.0, || false)); + assert!(!record_needs_tick(&record, 1.0, 1.0, || false)); } #[test] @@ -727,8 +715,8 @@ mod tests { .unwrap() .visual .set_target(Rect::new(10.0, 0.0, 400.0, 300.0)); - assert!(record_needs_tick(&record, 1.0, 0.0, || true)); - assert!(!record_needs_tick(&record, 1.0, 0.0, || false)); + assert!(record_needs_tick(&record, 1.0, 1.0, || true)); + assert!(!record_needs_tick(&record, 1.0, 1.0, || false)); let mut record = settled(); record.geometry.as_mut().unwrap().client.request_size( @@ -738,23 +726,23 @@ mod tests { }, Duration::ZERO, ); - assert!(record_needs_tick(&record, 1.0, 0.0, || true)); + assert!(record_needs_tick(&record, 1.0, 1.0, || true)); record.resize = Some(crate::resize_transaction::ResizeTransaction::new( 1.into(), Duration::ZERO, )); - assert!(record_needs_tick(&record, 1.0, 0.0, || false)); + assert!(record_needs_tick(&record, 1.0, 1.0, || false)); } #[test] fn opening_and_coupled_width_cleanup_still_get_their_final_tick() { let mut record = settled(); record.opening = Some(AnimatedValue::new(0.0)); - assert!(record_needs_tick(&record, 1.0, 0.0, || false)); + assert!(record_needs_tick(&record, 1.0, 1.0, || false)); record.opening = None; - assert!(!record_needs_tick(&record, 1.0, 0.0, || true)); + assert!(!record_needs_tick(&record, 1.0, 1.0, || true)); record.coupled_width = Some((WorkspaceId(1), AnimatedValue::new(400.0))); - assert!(record_needs_tick(&record, 1.0, 0.0, || true)); + assert!(record_needs_tick(&record, 1.0, 1.0, || true)); } #[test] diff --git a/compositor/src/state/capture_privacy_tests.rs b/compositor/src/state/capture_privacy_tests.rs index 2da773e..20928cb 100644 --- a/compositor/src/state/capture_privacy_tests.rs +++ b/compositor/src/state/capture_privacy_tests.rs @@ -570,7 +570,6 @@ fn capture_privacy_pixels_and_policy_transitions() { radius: 0.0, shape: crate::presentation::CornerShape::Continuous, decorations: 0.0, - dim: 0.0, fill: None, material: None, }); diff --git a/compositor/src/state/lifecycle.rs b/compositor/src/state/lifecycle.rs index 27ff880..636e822 100644 --- a/compositor/src/state/lifecycle.rs +++ b/compositor/src/state/lifecycle.rs @@ -49,7 +49,6 @@ impl Ferese { let record = self.windows.record(id).unwrap(); let geometry = record.geometry.unwrap(); let decorations = geometry.decorations.clamp(0.0, 1.0); - let dim = record.dimming.as_ref().map_or(0.0, |dim| dim.current); let handoff_size = (!presentation.scale_content).then(|| { presentation .native_size @@ -138,7 +137,6 @@ impl Ferese { radius, shape: crate::render::window_corner_shape(window), decorations, - dim, fill, material, }); diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index 20094dd..41179e4 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -13,7 +13,6 @@ pub(crate) struct FrameScene { pub(crate) struct WindowFrame { pub geometry: WindowGeometry, pub presentation: crate::presentation::WindowPresentation, - pub dim: f64, } impl Ferese { @@ -28,6 +27,26 @@ impl Ferese { )) } + fn window_focus_effect(&self, id: WindowId, delta: Duration, overview_opacity: f32) -> (f32, f64) { + let target = crate::focus_effect::target(self.focus_effect, self.focused_window, id); + let mut focus = self + .windows + .record(id) + .and_then(|record| record.focus_transition.clone()) + .unwrap_or_else(|| crate::focus_effect::BoundedFade::new(target)); + focus.advance_visual( + target, + delta, + if self.animations_enabled && self.focus_effect.enabled { + self.focus_effect.duration_ms + } else { + 0.0 + }, + ); + self.focus_effect + .resolve(focus.current, self.focused_window.is_some(), overview_opacity) + } + fn compose_window_presentation( &self, id: WindowId, @@ -110,8 +129,11 @@ impl Ferese { ); bounds.current = crate::presentation::scaled_visual_rect(bounds.current, presence_scale); bounds.target = bounds.current; + let (focus_alpha, dim) = self.window_focus_effect(id, delta, overview.opacity()); crate::presentation::WindowPresentation { id, + focus_alpha, + dim, bounds, opacity, emphasis, @@ -161,13 +183,8 @@ impl Ferese { .shadow .as_ref() .is_some_and(|shadow| shadow.needs_update(if selected == Some(id) { 1.0 } else { 0.0 })) - || record.dimming.as_ref().is_some_and(|dim| { - dim.needs_update(crate::dimming::target( - self.inactive_dim, - self.focused_window, - id, - self.overview.is_presenting(), - )) + || record.focus_transition.as_ref().is_some_and(|transition| { + transition.needs_update(crate::focus_effect::target(self.focus_effect, self.focused_window, id)) })) }) } @@ -209,7 +226,10 @@ impl Ferese { || self.render.snapshot(&id).is_some() || record.focus.as_ref().is_some_and(|focus| focus.is_animating()) || record.shadow.as_ref().is_some_and(|shadow| shadow.is_animating()) - || record.dimming.as_ref().is_some_and(|dim| dim.is_animating()) + || record + .focus_transition + .as_ref() + .is_some_and(|transition| transition.is_animating()) { return true; } @@ -298,18 +318,7 @@ impl Ferese { .and_then(|workspace| slide_offsets.get(&workspace).copied()) .unwrap_or_default(); let presentation = self.compose_window_presentation(id, geometry, &overview, offset, delta); - let mut dim = record.dimming.clone(); - if let Some(dim) = &mut dim { - dim.predict(delta, self.inactive_dim.duration_ms); - } - windows.insert( - id, - WindowFrame { - geometry, - presentation, - dim: dim.map_or(0.0, |dim| dim.current), - }, - ); + windows.insert(id, WindowFrame { geometry, presentation }); } FrameScene { @@ -375,6 +384,124 @@ fn predict_geometry( mod tests { use super::*; + #[test] + #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] + fn focus_effect_sampling_preserves_activation_and_output_local_progress() { + let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap()); + assert!(runtime.starts_with(std::env::temp_dir())); + let mut event_loop = EventLoop::try_new().unwrap(); + let mut state = Ferese::new( + &mut event_loop, + Display::new().unwrap(), + crate::config::Config::default().runtime_config().unwrap(), + ) + .unwrap(); + state.focus_effect = crate::config::FocusEffectSettings { + enabled: true, + active_opacity: 0.9, + inactive_opacity: 0.6, + inactive_dim: 0.2, + duration_ms: 150.0, + }; + let mut outputs = Vec::new(); + for index in 0..2 { + let output = Output::new( + format!("focus-{index}"), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (800, 600).into(), + refresh: 60_000, + }), + Some(smithay::utils::Transform::Normal), + None, + Some((index * 800, 0).into()), + ); + state.space.map_output(&output, (index * 800, 0)); + state.register_output(&output, format!("focus-{index}")); + let workspace = state + .output_workspaces + .active_workspace(state.output_id(&output).unwrap()) + .unwrap(); + let id = WindowId(index as u64 + 1); + let rect = Rect::new(index as f64 * 800.0, 0.0, 400.0, 300.0); + state + .workspaces + .insert_floating_window(id, workspace, rect, false) + .unwrap(); + state.windows.records.insert( + id, + super::super::window_registry::WindowRecord { + geometry: Some(WindowGeometry::new(rect, Some(ClientSize::from_rect(rect)))), + focus_transition: Some(crate::focus_effect::BoundedFade::new(if index == 0 { + 1.0 + } else { + 0.0 + })), + ..Default::default() + }, + ); + outputs.push(output); + } + let first = WindowId(1); + let second = WindowId(2); + state.focused_window = Some(first); + assert_eq!(state.current_window_presentation(first).unwrap().alpha(), 0.9); + assert_eq!(state.current_window_presentation(second).unwrap().alpha(), 0.6); + // Only emphasis follows overview selection; activation and its fade remain unchanged. + state.set_overview_active(true); + state.overview.select_window(second); + assert_eq!(state.focused_window, Some(first)); + assert_eq!(state.current_window_presentation(first).unwrap().alpha(), 0.9); + assert_eq!(state.current_window_presentation(second).unwrap().alpha(), 0.6); + state.set_overview_active(false); + state.focused_window = Some(second); + let transition = state + .windows + .record_mut(first) + .unwrap() + .focus_transition + .as_mut() + .unwrap(); + transition.advance_visual(0.0, Duration::ZERO, 150.0); + let previous = transition.current; + assert!(state.output_has_pending_visual_changes(&outputs[0])); + let frame = state.sample_frame(&outputs[0], Duration::from_millis(75)); + assert!((frame.windows[&first].presentation.alpha() - 0.75).abs() < 1e-6); + assert!(!frame.windows.contains_key(&second)); + assert_eq!( + state + .windows + .record(first) + .unwrap() + .focus_transition + .as_ref() + .unwrap() + .current, + previous + ); + // A pending client configure must not stop focus presentation from progressing. + state.windows.set_transaction( + first, + crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO), + ); + let waiting = state.sample_frame(&outputs[0], Duration::from_millis(75)); + assert_eq!( + waiting.windows[&first].presentation.alpha(), + frame.windows[&first].presentation.alpha() + ); + assert!(state.windows.transaction(&first).is_some()); + state.animations_enabled = false; + assert_eq!(state.current_window_presentation(first).unwrap().alpha(), 0.6); + assert_eq!(state.current_window_presentation(second).unwrap().alpha(), 0.9); + } + #[test] #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] fn overview_strip_switch_slides_stable_grids_and_keeps_strip_fixed() { diff --git a/compositor/src/state/window_registry.rs b/compositor/src/state/window_registry.rs index 106c41c..21c2240 100644 --- a/compositor/src/state/window_registry.rs +++ b/compositor/src/state/window_registry.rs @@ -23,7 +23,7 @@ pub(crate) struct WindowRecord { pub resize: Option, pub focus: Option, pub shadow: Option, - pub dimming: Option, + pub focus_transition: Option, pub opening: Option, pub mapped_once: bool, pub world_x: Option<(WorkspaceId, AnimatedValue)>, diff --git a/docs/configuration.md b/docs/configuration.md index 40747aa..a45795c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -274,9 +274,11 @@ one appearance. | `theme.accessibility.increase-contrast` | boolean | `false` | Strengthen text and borders | | `theme.accessibility.reduce-transparency` | boolean | `false` | Solid surfaces; skip background blur | | `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius | -| `appearance.inactive-dim.enabled` | boolean | `false` | Dim unfocused windows | -| `appearance.inactive-dim.amount` | number 0–1 | `0.15` | Darkening strength | -| `appearance.inactive-dim.duration-ms` | number ≥ 0 | `150` | Dimming transition; 0 snaps | +| `appearance.focus-effect.enabled` | boolean | `true` | Apply window opacity and dimming; disabling keeps the configured values | +| `appearance.focus-effect.active-opacity` | number 0–1 | `1` | Active application opacity, including fullscreen | +| `appearance.focus-effect.inactive-opacity` | number 0–1 | `1` | Other application window opacity | +| `appearance.focus-effect.inactive-dim` | number 0–1 | `0` | Darkening strength for inactive windows; 0 disables | +| `appearance.focus-effect.duration-ms` | number ≥ 0 | `150` | Shared opacity and dimming transition; 0 snaps | | `theme.typography.font-family` | string | `"Inter"` | Shell, Settings and overview font | | `theme.background.path` | string | matching light/dark wallpaper | Shared wallpaper; an explicit path overrides both bundled defaults | | `theme.light.background.path`, `theme.dark.background.path` | string | shared path, or matching default | Wallpaper for one appearance; overrides the shared path | @@ -287,6 +289,12 @@ one appearance. | `theme.material.tint-strength` | number 0–1 | Dark `0.5`, light `1` | Color strength over the blurred backdrop | | `theme.material.blur-radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables | +Window opacity follows the active application. Giving keyboard focus to a shell +menu does not change it. Overview selection changes the highlight, while window +opacity still follows activation; dimming fades out as overview opens. Fullscreen +windows use the same opacity settings. Opening and closing fades multiply window +opacity. Reduced motion disables the focus transition. + Settings → Wallpaper shows the current image and both bundled defaults. Use **Apply to** to change both modes or just one. **Match appearance** restores the bundled light and dark pair. Custom images can also be selected separately in KDL: diff --git a/packaging/config.kdl b/packaging/config.kdl index 61ff89f..e112990 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -102,9 +102,11 @@ animations { } appearance { - inactive-dim { - enabled #false - amount 0.15 + focus-effect { + enabled #true + active-opacity 1.0 + inactive-opacity 1.0 + inactive-dim 0.0 duration-ms 150 } } diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 8b0b94b..37472c4 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -1016,19 +1016,45 @@ pub fn fields(page: Page) -> Vec { }, ), toggle( - "appearance.inactive_dim.enabled", - "Dim inactive windows", - "Keep attention on the window you are using.", - false, + "appearance.focus_effect.enabled", + "Window focus effects", + "Apply window opacity and inactive dimming. Turning this off keeps your settings.", + true, ), range( - "appearance.inactive_dim.amount", - "Dimming amount", - "How much inactive windows recede.", + "appearance.focus_effect.active_opacity", + "Active window opacity", + "Opacity of the active application, including fullscreen windows.", RangeSpec { - default: 0.15, + default: 1.0, min: 0.0, - max: 0.5, + max: 1.0, + step: 0.01, + suffix: "", + integer: false, + }, + ), + range( + "appearance.focus_effect.inactive_opacity", + "Inactive window opacity", + "Opacity of other application windows.", + RangeSpec { + default: 1.0, + min: 0.0, + max: 1.0, + step: 0.01, + suffix: "", + integer: false, + }, + ), + range( + "appearance.focus_effect.inactive_dim", + "Inactive window dimming", + "Darken inactive windows. Zero disables dimming.", + RangeSpec { + default: 0.0, + min: 0.0, + max: 1.0, step: 0.01, suffix: "", integer: false, @@ -1056,9 +1082,9 @@ pub fn fields(page: Page) -> Vec { }, ), range( - "appearance.inactive_dim.duration_ms", + "appearance.focus_effect.duration_ms", "Focus transition", - "Time taken to dim an inactive window.", + "Duration of opacity and dimming changes when the active window changes.", RangeSpec { default: 150.0, min: 0.0,