Add unified window focus opacity and dimming effects

This commit is contained in:
Samuel Onoja committed 2026-10-07 07:00:30 +01:00
1 parent 02ec7e5199
commit bbc5b27c25
21 files changed
+553 -342

No files matched your search

+31 -19
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup}; pub(crate) use self::xwayland::{XwaylandConfig, XwaylandStartup};
use appearance::*; use appearance::*;
pub use appearance::{BorderGradient, InactiveDimSettings, MaterialStyle, ThemeSettings}; pub use appearance::{BorderGradient, FocusEffectSettings, MaterialStyle, ThemeSettings};
use bindings::*; use bindings::*;
pub use bindings::{Binding, BindingAction}; pub use bindings::{Binding, BindingAction};
pub(crate) use bindings::{BindingSet, physical_keymap}; pub(crate) use bindings::{BindingSet, physical_keymap};
@@ -137,7 +137,7 @@ impl Config {
bindings, bindings,
window_rules: self.window_rules()?, window_rules: self.window_rules()?,
theme_settings: self.theme_settings()?, theme_settings: self.theme_settings()?,
inactive_dim: self.inactive_dim_settings()?, focus_effect: self.focus_effect_settings()?,
default_column_width: self.default_column_width()?, default_column_width: self.default_column_width()?,
scrolling_focus_strategy: self.scrolling_focus_strategy(), scrolling_focus_strategy: self.scrolling_focus_strategy(),
column_width_presets: self.width_presets()?, column_width_presets: self.width_presets()?,
@@ -880,31 +880,43 @@ mod tests {
} }
#[test] #[test]
fn inactive_dimming_is_opt_in_and_configurable() { fn focus_effect_defaults_and_validation() {
let defaults = parse("").inactive_dim_settings().unwrap(); let defaults = parse("").focus_effect_settings().unwrap();
assert!(!defaults.enabled); assert!(defaults.enabled);
assert_eq!(defaults.amount, 0.15); 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); assert_eq!(defaults.duration_ms, 150.0);
let settings = let settings = parse("appearance { focus-effect { active-opacity 0.9; inactive-opacity 0.8; inactive-dim 0.25; duration-ms 100; }; }")
parse("appearance {\n inactive-dim {\n enabled #true\n amount 0.25\n duration-ms 100\n }\n}\n") .focus_effect_settings().unwrap();
.inactive_dim_settings()
.unwrap();
assert!(settings.enabled); 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); assert_eq!(settings.duration_ms, 100.0);
for (key, value) in [ for key in ["active-opacity", "inactive-opacity", "inactive-dim"] {
("amount", "-0.1"), for value in ["-0.1", "1.1", "#nan", "#inf"] {
("amount", "1.1"), assert!(
("amount", "#nan"), Config::parse_source(&format!("appearance {{ focus-effect {{ {key} {value}; }} }}"))
("duration_ms", "-1"), .and_then(|config| config.runtime_config())
("duration_ms", "#inf"), .is_err()
] { );
}
}
for value in ["-1", "#nan", "#inf"] {
assert!( 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()) .and_then(|config| config.runtime_config())
.is_err() .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] #[test]
+24 -17
View File
@@ -1,14 +1,14 @@
use super::*; use super::*;
impl Config { impl Config {
pub(crate) fn inactive_dim_settings(&self) -> Result<InactiveDimSettings, ConfigError> { pub(crate) fn focus_effect_settings(&self) -> Result<FocusEffectSettings, ConfigError> {
Ok(InactiveDimSettings { let effect = &self.appearance.focus_effect;
enabled: self.appearance.inactive_dim.enabled, Ok(FocusEffectSettings {
amount: unit_theme_value(self.appearance.inactive_dim.amount, "appearance.inactive_dim.amount")?, enabled: effect.enabled,
duration_ms: nonnegative_theme_value( active_opacity: unit_theme_value(effect.active_opacity, "appearance.focus_effect.active_opacity")?,
self.appearance.inactive_dim.duration_ms, inactive_opacity: unit_theme_value(effect.inactive_opacity, "appearance.focus_effect.inactive_opacity")?,
"appearance.inactive_dim.duration_ms", 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)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct AppearanceConfig { pub(super) struct AppearanceConfig {
pub(super) corner_radius: Option<f64>, pub(super) corner_radius: Option<f64>,
#[serde(default)] #[serde(default)]
pub(super) inactive_dim: InactiveDimConfig, pub(super) focus_effect: FocusEffectConfig,
} }
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct InactiveDimSettings { pub struct FocusEffectSettings {
pub enabled: bool, pub enabled: bool,
pub amount: f64, pub active_opacity: f64,
pub inactive_opacity: f64,
pub inactive_dim: f64,
pub duration_ms: f64, pub duration_ms: f64,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(default)] #[serde(default, deny_unknown_fields)]
pub(super) struct InactiveDimConfig { pub(super) struct FocusEffectConfig {
pub(super) enabled: bool, 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, pub(super) duration_ms: f64,
} }
impl Default for InactiveDimConfig { impl Default for FocusEffectConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: true,
amount: 0.15, active_opacity: 1.0,
inactive_opacity: 1.0,
inactive_dim: 0.0,
duration_ms: 150.0, duration_ms: 150.0,
} }
} }
-174
View File
@@ -1,174 +0,0 @@
use std::time::Duration;
use ferese_layout::WindowId;
use crate::config::InactiveDimSettings;
pub(crate) fn target(
settings: InactiveDimSettings,
focused: Option<WindowId>,
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);
}
}
+155
View File
@@ -0,0 +1,155 @@
use std::time::Duration;
use crate::config::FocusEffectSettings;
use ferese_layout::WindowId;
pub(crate) fn target(settings: FocusEffectSettings, active: Option<WindowId>, 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));
}
}
+1 -1
View File
@@ -532,7 +532,7 @@ impl Ferese {
self.dismissing_popups.push(( self.dismissing_popups.push((
root.clone(), root.clone(),
popup, popup,
crate::dimming::DimAnimation::new(f64::from(opacity)), crate::focus_effect::BoundedFade::new(f64::from(opacity)),
)); ));
pointer.unset_grab(self, serial, event.time_msec()); pointer.unset_grab(self, serial, event.time_msec());
self.focus_window_at(pointer.current_location(), serial, true); self.focus_window_at(pointer.current_location(), serial, true);
+1 -1
View File
@@ -2,11 +2,11 @@ mod backends;
mod config; mod config;
mod cursor; mod cursor;
mod daemon; mod daemon;
mod dimming;
mod display_presentation; mod display_presentation;
mod dmabuf_imports; mod dmabuf_imports;
mod effects; mod effects;
mod floating; mod floating;
mod focus_effect;
mod frame_scheduler; mod frame_scheduler;
mod gestures; mod gestures;
mod grabs; mod grabs;
+2
View File
@@ -1121,6 +1121,8 @@ mod tests {
let scale = 0.97 + 0.03 * opacity.current; let scale = 0.97 + 0.03 * opacity.current;
let id = WindowId(1); let id = WindowId(1);
let presentation = WindowPresentation { let presentation = WindowPresentation {
focus_alpha: 1.0,
dim: 0.0,
id, id,
bounds: AnimatedRect { bounds: AnimatedRect {
current: scaled_visual_rect(normal, scale), current: scaled_visual_rect(normal, scale),
+29 -3
View File
@@ -22,8 +22,10 @@ pub(crate) const SNAPSHOT_BUDGET: usize = 64 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct WindowPresentation { pub(crate) struct WindowPresentation {
pub id: ferese_layout::WindowId, pub id: ferese_layout::WindowId,
pub bounds: ferese_animation::AnimatedRect, pub bounds: AnimatedRect,
pub opacity: ferese_animation::AnimatedValue, pub opacity: ferese_animation::AnimatedValue,
pub focus_alpha: f32,
pub dim: f64,
pub emphasis: ferese_animation::AnimatedValue, pub emphasis: ferese_animation::AnimatedValue,
pub shadow: ferese_animation::AnimatedValue, pub shadow: ferese_animation::AnimatedValue,
pub scale_content: bool, pub scale_content: bool,
@@ -32,7 +34,7 @@ pub(crate) struct WindowPresentation {
impl WindowPresentation { impl WindowPresentation {
pub fn alpha(self) -> f32 { 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 { pub fn focus(self) -> f64 {
@@ -41,7 +43,7 @@ impl WindowPresentation {
/// A thumbnail is another view of the same content and lifecycle state. /// A thumbnail is another view of the same content and lifecycle state.
pub fn thumbnail(mut self, rect: ferese_layout::Rect) -> Self { 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.scale_content = true;
self.native_size = None; self.native_size = None;
self self
@@ -362,9 +364,33 @@ impl RenderElement<GlesRenderer> for NativeTextureElement {
#[cfg(test)] #[cfg(test)]
mod tests { 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 { fn moving_presentation() -> WindowPresentation {
use ferese_animation::{AnimatedRect, AnimatedValue, RectVelocity}; use ferese_animation::{AnimatedRect, AnimatedValue, RectVelocity};
WindowPresentation { WindowPresentation {
focus_alpha: 1.0,
dim: 0.0,
id: ferese_layout::WindowId(7), id: ferese_layout::WindowId(7),
bounds: AnimatedRect { bounds: AnimatedRect {
velocity: RectVelocity { velocity: RectVelocity {
+30 -15
View File
@@ -407,6 +407,7 @@ mod tests {
.env_remove("FERESE_SOCKET") .env_remove("FERESE_SOCKET")
.output() .output()
.unwrap(); .unwrap();
assert!( assert!(
result.status.success(), result.status.success(),
"{}\n{}", "{}\n{}",
@@ -458,7 +459,7 @@ mod tests {
} }
#[test] #[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() { if std::env::var_os("FERESE_DIM_RELOAD_TEST_CHILD").is_none() {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -467,7 +468,7 @@ mod tests {
let output = std::process::Command::new(std::env::current_exe().unwrap()) let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([ .args([
"--exact", "--exact",
"reload::tests::live_reload_keeps_dimming_policy_independent_of_theme_transitions", "reload::tests::live_reload_keeps_focus_effect_independent_of_theme_transitions",
"--nocapture", "--nocapture",
]) ])
.env("FERESE_DIM_RELOAD_TEST_CHILD", "1") .env("FERESE_DIM_RELOAD_TEST_CHILD", "1")
@@ -484,7 +485,7 @@ mod tests {
); );
return; 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 directory = Directory::new();
let (_, runtime, candidate) = crate::theme::prepare(source, &directory.0).unwrap(); let (_, runtime, candidate) = crate::theme::prepare(source, &directory.0).unwrap();
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().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.theme = candidate.theme.clone();
state.theme_engine.live.presented = candidate.theme; state.theme_engine.live.presented = candidate.theme;
for enabled in [false, true, false] { for inactive_opacity in [1.0, 0.75, 0.9] {
let source = format!("appearance {{ inactive-dim {{ enabled #{enabled}; amount 0.25; }}; }}"); let source =
format!("appearance {{ focus-effect {{ inactive-opacity {inactive_opacity}; inactive-dim 0.25; }}; }}");
state.reload_config_source(source).unwrap(); state.reload_config_source(source).unwrap();
assert_eq!(state.inactive_dim.enabled, enabled); assert_eq!(state.focus_effect.inactive_opacity, inactive_opacity);
let opacity = crate::dimming::target( let (alpha, dim) = state.focus_effect.resolve(0.0, true, 0.0);
state.inactive_dim, assert_eq!(alpha, inactive_opacity as f32);
Some(ferese_layout::WindowId(1)), assert_eq!(dim, 0.25);
ferese_layout::WindowId(2),
false,
);
assert_eq!(opacity, if enabled { 0.25 } else { 0.0 });
} }
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 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(); .unwrap();
state.poll_theme(); 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 /// The status endpoint must be able to tell "the live service is running
+1 -2
View File
@@ -15,7 +15,6 @@ pub(crate) struct ClosedWindow {
pub radius: f64, pub radius: f64,
pub shape: CornerShape, pub shape: CornerShape,
pub decorations: f64, pub decorations: f64,
pub dim: f64,
pub fill: Option<[f32; 4]>, pub fill: Option<[f32; 4]>,
pub material: Option<( pub material: Option<(
smithay::reexports::wayland_server::protocol::wl_surface::WlSurface, smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
@@ -110,7 +109,7 @@ pub(super) fn grouped_elements(
window.presentation.id, window.presentation.id,
constrain, constrain,
corners, 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, false,
output, output,
) { ) {
+18 -11
View File
@@ -153,10 +153,17 @@ fn scene_elements(
} }
let sample = frame.windows.get(&id)?; let sample = frame.windows.get(&id)?;
let visual = sample.presentation.bounds.current; 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); 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() { let windows = if state.overview.is_active() {
state state
@@ -180,7 +187,7 @@ fn scene_elements(
windows.iter().map(|(_, id, ..)| *id), windows.iter().map(|(_, id, ..)| *id),
frame.delta, 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)) { if let Some(group) = closing.remove(&Some(id)) {
elements.extend(group); elements.extend(group);
} }
@@ -205,14 +212,14 @@ fn scene_elements(
let scale_content = sample.presentation.scale_content; let scale_content = sample.presentation.scale_content;
let behavior = resize_content_behavior(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( if let Some(overlay) = window_tint_element(
&mut state.render, &mut state.render,
renderer, renderer,
id, id,
constrain, constrain,
corners, corners,
[0.0, 0.0, 0.0, dim as f32 * close_alpha], [0.0, 0.0, 0.0, dim as f32 * presentation_alpha],
false, false,
output, output,
) { ) {
@@ -244,7 +251,7 @@ fn scene_elements(
border_color, border_color,
gradient, gradient,
focus as f32, focus as f32,
close_alpha * decoration_progress as f32, presentation_alpha * decoration_progress as f32,
output, output,
&programs, &programs,
) { ) {
@@ -260,7 +267,7 @@ fn scene_elements(
scale, scale,
shadow_offset_y * offset_factor, shadow_offset_y * offset_factor,
shadow_blur * blur_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, shadow_color,
output, output,
&programs, &programs,
@@ -286,7 +293,7 @@ fn scene_elements(
&window, &window,
corners, corners,
scale, scale,
close_alpha, presentation_alpha,
sample.geometry.presentation_changed || corner_shape_changed, sample.geometry.presentation_changed || corner_shape_changed,
output, output,
programs.clone(), programs.clone(),
@@ -299,7 +306,7 @@ fn scene_elements(
&& (source.w < constrain.size.w || source.h < constrain.size.h) && (source.w < constrain.size.w || source.h < constrain.size.h)
{ {
let mut color = state.theme_settings.surface_base_color.0; let mut color = state.theme_settings.surface_base_color.0;
color[3] = close_alpha; color[3] = presentation_alpha;
if let Some(fill) = if let Some(fill) =
window_tint_element(&mut state.render, renderer, id, constrain, corners, color, true, output) window_tint_element(&mut state.render, renderer, id, constrain, corners, color, true, output)
{ {
@@ -321,7 +328,7 @@ fn scene_elements(
corners, corners,
index: 0, index: 0,
capture_geometry: constrain, capture_geometry: constrain,
alpha: close_alpha, alpha: presentation_alpha,
}, },
) )
{ {
@@ -335,7 +342,7 @@ fn scene_elements(
renderer, renderer,
&window, &window,
constrain.loc, constrain.loc,
close_alpha, presentation_alpha,
scale, scale,
constrain, constrain,
ConstrainBehavior { ConstrainBehavior {
+17 -3
View File
@@ -84,8 +84,13 @@ mod tests {
); );
let mut resources = RenderResources::default(); let mut resources = RenderResources::default();
let programs = corner_program(&mut resources, &mut renderer, CornerShape::Continuous).unwrap(); 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 { let live = crate::presentation::WindowPresentation {
focus_alpha,
dim: 0.0,
id: ferese_layout::WindowId(1), id: ferese_layout::WindowId(1),
bounds: AnimatedRect::new(ferese_layout::Rect::new(20.25, 20.5, 32.25, 24.25)), bounds: AnimatedRect::new(ferese_layout::Rect::new(20.25, 20.5, 32.25, 24.25)),
opacity: AnimatedValue::new(1.0), opacity: AnimatedValue::new(1.0),
@@ -147,7 +152,16 @@ mod tests {
pixels.push(renderer.map_texture(&mapping).unwrap().to_vec()); pixels.push(renderer.map_texture(&mapping).unwrap().to_vec());
} }
assert_eq!(pixels[0], pixels[1], "close handoff changed pixels at scale {scale}"); 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 { let mut retained = super::super::closing::ClosedWindow {
presentation: closing, presentation: closing,
output: ferese_core::OutputId(1), output: ferese_core::OutputId(1),
@@ -157,7 +171,6 @@ mod tests {
radius: 8.0, radius: 8.0,
shape: CornerShape::Continuous, shape: CornerShape::Continuous,
decorations: 1.0, decorations: 1.0,
dim: 0.0,
fill: None, fill: None,
material: None, material: None,
}; };
@@ -166,6 +179,7 @@ mod tests {
retained.handoff.as_mut().unwrap().0.elapsed = Duration::from_millis(20); retained.handoff.as_mut().unwrap().0.elapsed = Duration::from_millis(20);
let mut predicted = retained.clone(); let mut predicted = retained.clone();
predicted.advance(Duration::from_millis(20), Default::default()); predicted.advance(Duration::from_millis(20), Default::default());
assert_eq!(predicted.presentation.focus_alpha, focus_alpha);
let faded = &predicted.handoff.as_ref().unwrap().0; let faded = &predicted.handoff.as_ref().unwrap().0;
assert_eq!(faded.elapsed, Duration::from_millis(40)); assert_eq!(faded.elapsed, Duration::from_millis(40));
assert_eq!(retained.handoff.as_ref().unwrap().0.elapsed, Duration::from_millis(20)); assert_eq!(retained.handoff.as_ref().unwrap().0.elapsed, Duration::from_millis(20));
+6 -6
View File
@@ -83,7 +83,7 @@ use window_registry::WindowRegistry;
use crate::backends::direct::DirectBackendState; use crate::backends::direct::DirectBackendState;
use crate::config::{Binding, BindingSet, DaemonConfig, InputSettings, OutputProfile, ThemeSettings}; use crate::config::{Binding, BindingSet, DaemonConfig, InputSettings, OutputProfile, ThemeSettings};
use crate::cursor::NamedCursor; use crate::cursor::NamedCursor;
use crate::dimming::DimAnimation; use crate::focus_effect::BoundedFade;
use crate::gestures::{Swipe, SwipeDirection}; use crate::gestures::{Swipe, SwipeDirection};
use crate::handlers::screencopy::PendingScreencopy; use crate::handlers::screencopy::PendingScreencopy;
use crate::handlers::screenshot::{Coordinator, PartSender}; use crate::handlers::screenshot::{Coordinator, PartSender};
@@ -460,7 +460,7 @@ pub struct Ferese {
workspace_auto_back_and_forth: bool, workspace_auto_back_and_forth: bool,
pub(crate) window_rules: Vec<WindowRule>, pub(crate) window_rules: Vec<WindowRule>,
pub(crate) theme_settings: ThemeSettings, pub(crate) theme_settings: ThemeSettings,
pub(crate) inactive_dim: crate::config::InactiveDimSettings, pub(crate) focus_effect: crate::config::FocusEffectSettings,
animations_enabled: bool, animations_enabled: bool,
animation_speed: f64, animation_speed: f64,
pub(crate) spring_config: SpringConfig, pub(crate) spring_config: SpringConfig,
@@ -505,7 +505,7 @@ pub struct Ferese {
#[cfg(test)] #[cfg(test)]
animation_test_time: Option<Duration>, animation_test_time: Option<Duration>,
pub popups: PopupManager, pub popups: PopupManager,
pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, DimAnimation)>, pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, BoundedFade)>,
pub seat: Seat<Self>, pub seat: Seat<Self>,
pub alpha_modifier_state: AlphaModifierState, pub alpha_modifier_state: AlphaModifierState,
pub compositor_state: CompositorState, pub compositor_state: CompositorState,
@@ -551,7 +551,7 @@ pub struct RuntimeConfig {
pub workspace_auto_back_and_forth: bool, pub workspace_auto_back_and_forth: bool,
pub window_rules: Vec<WindowRule>, pub window_rules: Vec<WindowRule>,
pub theme_settings: ThemeSettings, pub theme_settings: ThemeSettings,
pub inactive_dim: crate::config::InactiveDimSettings, pub focus_effect: crate::config::FocusEffectSettings,
pub default_column_width: ColumnWidth, pub default_column_width: ColumnWidth,
pub scrolling_focus_strategy: ViewportFocusStrategy, pub scrolling_focus_strategy: ViewportFocusStrategy,
pub column_width_presets: Vec<ColumnWidth>, pub column_width_presets: Vec<ColumnWidth>,
@@ -717,7 +717,7 @@ impl Ferese {
workspace_auto_back_and_forth: config.workspace_auto_back_and_forth, workspace_auto_back_and_forth: config.workspace_auto_back_and_forth,
window_rules: config.window_rules, window_rules: config.window_rules,
theme_settings: config.theme_settings, theme_settings: config.theme_settings,
inactive_dim: config.inactive_dim, focus_effect: config.focus_effect,
animations_enabled: config.animations_enabled, animations_enabled: config.animations_enabled,
animation_speed: config.animation_speed, animation_speed: config.animation_speed,
spring_config: config.spring_config, 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; let idle_policy_changed = self.idle_inhibit != config.idle_inhibit || old_rules != self.window_rules;
self.idle_inhibit = config.idle_inhibit; self.idle_inhibit = config.idle_inhibit;
self.theme_settings = config.theme_settings; 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.column_width_presets = config.column_width_presets;
self.animations_enabled = config.animations_enabled; self.animations_enabled = config.animations_enabled;
if !self.animations_enabled { if !self.animations_enabled {
+36 -48
View File
@@ -125,14 +125,9 @@ impl Ferese {
self.windows.records().any(|(&id, record)| { self.windows.records().any(|(&id, record)| {
let focus = if selected == Some(id) { 1.0 } else { 0.0 }; let focus = if selected == Some(id) { 1.0 } else { 0.0 };
let dim = crate::dimming::target( let activation = crate::focus_effect::target(self.focus_effect, self.focused_window, id);
self.inactive_dim,
self.focused_window,
id,
self.overview.is_presenting(),
);
record_needs_tick(record, focus, dim, || { record_needs_tick(record, focus, activation, || {
self.windows self.windows
.window(id) .window(id)
.is_some_and(|window| self.space.element_location(window).is_some()) .is_some_and(|window| self.space.element_location(window).is_some())
@@ -196,14 +191,14 @@ impl Ferese {
active active
}); });
let dim_settings = self.inactive_dim; let effect = self.focus_effect;
let duration = if self.animations_enabled { let duration = if self.animations_enabled && effect.enabled {
dim_settings.duration_ms effect.duration_ms
} else { } else {
0.0 0.0
}; };
let mut dim_changed = false; let mut appearance_changed = false;
// Include hidden workspace windows: overview can present them too. // Include hidden workspace windows: overview can present them too.
// Read selection once so these immutable fields can be borrowed alongside // Read selection once so these immutable fields can be borrowed alongside
// each record without allocating a temporary window-ID vector. // each record without allocating a temporary window-ID vector.
@@ -228,7 +223,7 @@ impl Ferese {
} else { } else {
focus.snap(); focus.snap();
} }
dim_changed |= previous != focus.current; appearance_changed |= previous != focus.current;
let shadow = record.shadow.get_or_insert_with(|| AnimatedValue::new(target)); let shadow = record.shadow.get_or_insert_with(|| AnimatedValue::new(target));
let previous = shadow.current; let previous = shadow.current;
let changed = shadow.target != target; let changed = shadow.target != target;
@@ -241,24 +236,15 @@ impl Ferese {
} else { } else {
shadow.snap(); shadow.snap();
} }
dim_changed |= previous != shadow.current; appearance_changed |= previous != shadow.current;
}
for window in self.space.elements() { let target = crate::focus_effect::target(effect, self.focused_window, id);
let Some(id) = self.windows.ids().get(window).copied() else { let transition = record
continue; .focus_transition
}; .get_or_insert_with(|| crate::focus_effect::BoundedFade::new(target));
let target = crate::dimming::target(dim_settings, self.focused_window, id, self.overview.is_presenting()); let previous = transition.current;
let Some(record) = self.windows.record_mut(id) else { active_animation |= transition.advance_visual(target, delta, duration);
continue; appearance_changed |= previous != transition.current;
};
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;
} }
for (_, record) in self.windows.records_mut() { for (_, record) in self.windows.records_mut() {
@@ -540,7 +526,7 @@ impl Ferese {
self.refresh_workspace_slide_offsets(); self.refresh_workspace_slide_offsets();
self.sync_window_stacking(); self.sync_window_stacking();
active_animation |= dim_changed; active_animation |= appearance_changed;
active_animation active_animation
} }
@@ -640,7 +626,7 @@ pub(super) fn sync_scrolling_coordinates(
fn record_needs_tick( fn record_needs_tick(
record: &super::window_registry::WindowRecord, record: &super::window_registry::WindowRecord,
focus: f64, focus: f64,
dim: f64, activation: f64,
mapped: impl FnOnce() -> bool, mapped: impl FnOnce() -> bool,
) -> bool { ) -> bool {
if record.resize.is_some() 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 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.world_x.as_ref().is_some_and(|(_, world)| moving(world))
|| record.coupled_width.is_some() || record.coupled_width.is_some()
|| record.geometry.is_some_and(|geometry| { || 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))) && 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. // A stale hidden target must not keep waking the whole desktop.
pending && mapped() pending && mapped()
} }
@@ -684,24 +672,24 @@ mod tests {
geometry: Some(geometry), geometry: Some(geometry),
focus: Some(AnimatedValue::new(1.0)), focus: Some(AnimatedValue::new(1.0)),
shadow: 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() ..Default::default()
} }
} }
#[test] #[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(); let record = settled();
for _ in 0..100 { 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" "settled record needs no mapping lookup"
))); )));
} }
assert!(record_needs_tick(&record, 0.0, 0.0, || true)); assert!(record_needs_tick(&record, 0.0, 1.0, || true));
assert!(record_needs_tick(&record, 1.0, 0.15, || true)); assert!(record_needs_tick(&record, 1.0, 0.0, || true));
assert!(!record_needs_tick(&record, 1.0, 0.15, || false)); assert!(!record_needs_tick(&record, 1.0, 0.0, || false));
} }
#[test] #[test]
@@ -710,12 +698,12 @@ mod tests {
let shadow = record.shadow.as_mut().unwrap(); let shadow = record.shadow.as_mut().unwrap();
shadow.current = 0.9; shadow.current = 0.9;
shadow.velocity = 0.2; 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( record.shadow.as_mut().unwrap().advance(
Duration::from_secs(3), Duration::from_secs(3),
crate::presentation::shadow_spring(SpringConfig::default()), 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] #[test]
@@ -727,8 +715,8 @@ mod tests {
.unwrap() .unwrap()
.visual .visual
.set_target(Rect::new(10.0, 0.0, 400.0, 300.0)); .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, 1.0, || true));
assert!(!record_needs_tick(&record, 1.0, 0.0, || false)); assert!(!record_needs_tick(&record, 1.0, 1.0, || false));
let mut record = settled(); let mut record = settled();
record.geometry.as_mut().unwrap().client.request_size( record.geometry.as_mut().unwrap().client.request_size(
@@ -738,23 +726,23 @@ mod tests {
}, },
Duration::ZERO, 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( record.resize = Some(crate::resize_transaction::ResizeTransaction::new(
1.into(), 1.into(),
Duration::ZERO, Duration::ZERO,
)); ));
assert!(record_needs_tick(&record, 1.0, 0.0, || false)); assert!(record_needs_tick(&record, 1.0, 1.0, || false));
} }
#[test] #[test]
fn opening_and_coupled_width_cleanup_still_get_their_final_tick() { fn opening_and_coupled_width_cleanup_still_get_their_final_tick() {
let mut record = settled(); let mut record = settled();
record.opening = Some(AnimatedValue::new(0.0)); 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; 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))); 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] #[test]
@@ -570,7 +570,6 @@ fn capture_privacy_pixels_and_policy_transitions() {
radius: 0.0, radius: 0.0,
shape: crate::presentation::CornerShape::Continuous, shape: crate::presentation::CornerShape::Continuous,
decorations: 0.0, decorations: 0.0,
dim: 0.0,
fill: None, fill: None,
material: None, material: None,
}); });
-2
View File
@@ -49,7 +49,6 @@ impl Ferese {
let record = self.windows.record(id).unwrap(); let record = self.windows.record(id).unwrap();
let geometry = record.geometry.unwrap(); let geometry = record.geometry.unwrap();
let decorations = geometry.decorations.clamp(0.0, 1.0); 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(|| { let handoff_size = (!presentation.scale_content).then(|| {
presentation presentation
.native_size .native_size
@@ -138,7 +137,6 @@ impl Ferese {
radius, radius,
shape: crate::render::window_corner_shape(window), shape: crate::render::window_corner_shape(window),
decorations, decorations,
dim,
fill, fill,
material, material,
}); });
+148 -21
View File
@@ -13,7 +13,6 @@ pub(crate) struct FrameScene {
pub(crate) struct WindowFrame { pub(crate) struct WindowFrame {
pub geometry: WindowGeometry, pub geometry: WindowGeometry,
pub presentation: crate::presentation::WindowPresentation, pub presentation: crate::presentation::WindowPresentation,
pub dim: f64,
} }
impl Ferese { 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( fn compose_window_presentation(
&self, &self,
id: WindowId, id: WindowId,
@@ -110,8 +129,11 @@ impl Ferese {
); );
bounds.current = crate::presentation::scaled_visual_rect(bounds.current, presence_scale); bounds.current = crate::presentation::scaled_visual_rect(bounds.current, presence_scale);
bounds.target = bounds.current; bounds.target = bounds.current;
let (focus_alpha, dim) = self.window_focus_effect(id, delta, overview.opacity());
crate::presentation::WindowPresentation { crate::presentation::WindowPresentation {
id, id,
focus_alpha,
dim,
bounds, bounds,
opacity, opacity,
emphasis, emphasis,
@@ -161,13 +183,8 @@ impl Ferese {
.shadow .shadow
.as_ref() .as_ref()
.is_some_and(|shadow| shadow.needs_update(if selected == Some(id) { 1.0 } else { 0.0 })) .is_some_and(|shadow| shadow.needs_update(if selected == Some(id) { 1.0 } else { 0.0 }))
|| record.dimming.as_ref().is_some_and(|dim| { || record.focus_transition.as_ref().is_some_and(|transition| {
dim.needs_update(crate::dimming::target( transition.needs_update(crate::focus_effect::target(self.focus_effect, self.focused_window, id))
self.inactive_dim,
self.focused_window,
id,
self.overview.is_presenting(),
))
})) }))
}) })
} }
@@ -209,7 +226,10 @@ impl Ferese {
|| self.render.snapshot(&id).is_some() || self.render.snapshot(&id).is_some()
|| record.focus.as_ref().is_some_and(|focus| focus.is_animating()) || record.focus.as_ref().is_some_and(|focus| focus.is_animating())
|| record.shadow.as_ref().is_some_and(|shadow| shadow.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; return true;
} }
@@ -298,18 +318,7 @@ impl Ferese {
.and_then(|workspace| slide_offsets.get(&workspace).copied()) .and_then(|workspace| slide_offsets.get(&workspace).copied())
.unwrap_or_default(); .unwrap_or_default();
let presentation = self.compose_window_presentation(id, geometry, &overview, offset, delta); let presentation = self.compose_window_presentation(id, geometry, &overview, offset, delta);
let mut dim = record.dimming.clone(); windows.insert(id, WindowFrame { geometry, presentation });
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),
},
);
} }
FrameScene { FrameScene {
@@ -375,6 +384,124 @@ fn predict_geometry(
mod tests { mod tests {
use super::*; 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] #[test]
#[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"]
fn overview_strip_switch_slides_stable_grids_and_keeps_strip_fixed() { fn overview_strip_switch_slides_stable_grids_and_keeps_strip_fixed() {
+1 -1
View File
@@ -23,7 +23,7 @@ pub(crate) struct WindowRecord {
pub resize: Option<ResizeTransaction>, pub resize: Option<ResizeTransaction>,
pub focus: Option<AnimatedValue>, pub focus: Option<AnimatedValue>,
pub shadow: Option<AnimatedValue>, pub shadow: Option<AnimatedValue>,
pub dimming: Option<crate::dimming::DimAnimation>, pub focus_transition: Option<crate::focus_effect::BoundedFade>,
pub opening: Option<AnimatedValue>, pub opening: Option<AnimatedValue>,
pub mapped_once: bool, pub mapped_once: bool,
pub world_x: Option<(WorkspaceId, AnimatedValue)>, pub world_x: Option<(WorkspaceId, AnimatedValue)>,
+11 -3
View File
@@ -274,9 +274,11 @@ one appearance.
| `theme.accessibility.increase-contrast` | boolean | `false` | Strengthen text and borders | | `theme.accessibility.increase-contrast` | boolean | `false` | Strengthen text and borders |
| `theme.accessibility.reduce-transparency` | boolean | `false` | Solid surfaces; skip background blur | | `theme.accessibility.reduce-transparency` | boolean | `false` | Solid surfaces; skip background blur |
| `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius | | `appearance.corner-radius` | number ≥ 0 | unset | Legacy fallback for shell radius |
| `appearance.inactive-dim.enabled` | boolean | `false` | Dim unfocused windows | | `appearance.focus-effect.enabled` | boolean | `true` | Apply window opacity and dimming; disabling keeps the configured values |
| `appearance.inactive-dim.amount` | number 0–1 | `0.15` | Darkening strength | | `appearance.focus-effect.active-opacity` | number 0–1 | `1` | Active application opacity, including fullscreen |
| `appearance.inactive-dim.duration-ms` | number ≥ 0 | `150` | Dimming transition; 0 snaps | | `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.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.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 | | `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.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 | | `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 Settings → Wallpaper shows the current image and both bundled defaults. Use
**Apply to** to change both modes or just one. **Match appearance** restores the **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: bundled light and dark pair. Custom images can also be selected separately in KDL:
+5 -3
View File
@@ -102,9 +102,11 @@ animations {
} }
appearance { appearance {
inactive-dim { focus-effect {
enabled #false enabled #true
amount 0.15 active-opacity 1.0
inactive-opacity 1.0
inactive-dim 0.0
duration-ms 150 duration-ms 150
} }
} }
+37 -11
View File
@@ -1016,19 +1016,45 @@ pub fn fields(page: Page) -> Vec<Field> {
}, },
), ),
toggle( toggle(
"appearance.inactive_dim.enabled", "appearance.focus_effect.enabled",
"Dim inactive windows", "Window focus effects",
"Keep attention on the window you are using.", "Apply window opacity and inactive dimming. Turning this off keeps your settings.",
false, true,
), ),
range( range(
"appearance.inactive_dim.amount", "appearance.focus_effect.active_opacity",
"Dimming amount", "Active window opacity",
"How much inactive windows recede.", "Opacity of the active application, including fullscreen windows.",
RangeSpec { RangeSpec {
default: 0.15, default: 1.0,
min: 0.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, step: 0.01,
suffix: "", suffix: "",
integer: false, integer: false,
@@ -1056,9 +1082,9 @@ pub fn fields(page: Page) -> Vec<Field> {
}, },
), ),
range( range(
"appearance.inactive_dim.duration_ms", "appearance.focus_effect.duration_ms",
"Focus transition", "Focus transition",
"Time taken to dim an inactive window.", "Duration of opacity and dimming changes when the active window changes.",
RangeSpec { RangeSpec {
default: 150.0, default: 150.0,
min: 0.0, min: 0.0,