Add unified window focus opacity and dimming effects
This commit is contained in:
1 parent
02ec7e5199
commit
bbc5b27c25
21 files changed
+553
-342
No files matched your search
+31
-19
@@ -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]
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use super::*;
|
||||
|
||||
impl Config {
|
||||
pub(crate) fn inactive_dim_settings(&self) -> Result<InactiveDimSettings, ConfigError> {
|
||||
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<FocusEffectSettings, ConfigError> {
|
||||
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<f64>,
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<GlesRenderer> 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 {
|
||||
|
||||
+30
-15
@@ -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
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<WindowRule>,
|
||||
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<Duration>,
|
||||
pub popups: PopupManager,
|
||||
pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, DimAnimation)>,
|
||||
pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, BoundedFade)>,
|
||||
pub seat: Seat<Self>,
|
||||
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<WindowRule>,
|
||||
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<ColumnWidth>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(crate) struct WindowRecord {
|
||||
pub resize: Option<ResizeTransaction>,
|
||||
pub focus: 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 mapped_once: bool,
|
||||
pub world_x: Option<(WorkspaceId, AnimatedValue)>,
|
||||
|
||||
+11
-3
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1016,19 +1016,45 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
},
|
||||
),
|
||||
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<Field> {
|
||||
},
|
||||
),
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user