Add configurable inactive dimming and viewport-paged scrolling

This commit is contained in:
Samuel Onoja committed 2026-09-26 09:41:55 +01:00
1 parent a0fd760737
commit 48ac963ffe
8 files changed
+607 -14

No files matched your search

+80
View File
@@ -114,6 +114,7 @@ pub struct ThemeSettings {
pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor,
pub panel_opacity: f64,
pub inactive_dim: InactiveDimSettings,
pub window_radius: f64,
pub shadow_offset_y: f64,
pub shadow_blur: f64,
@@ -135,6 +136,33 @@ pub enum MaterialStyle {
#[derive(Debug, Default, Deserialize)]
struct AppearanceConfig {
corner_radius: Option<f64>,
#[serde(default)]
inactive_dim: InactiveDimConfig,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InactiveDimSettings {
pub enabled: bool,
pub amount: f64,
pub duration_ms: f64,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
struct InactiveDimConfig {
enabled: bool,
amount: f64,
duration_ms: f64,
}
impl Default for InactiveDimConfig {
fn default() -> Self {
Self {
enabled: false,
amount: 0.15,
duration_ms: 150.0,
}
}
}
#[derive(Debug, Default, Deserialize)]
@@ -518,6 +546,7 @@ struct ScrollingConfig {
enum FocusStrategyValue {
Minimal,
CenterOnFocus,
Paged,
}
#[derive(Clone, Debug, Deserialize)]
@@ -680,6 +709,7 @@ impl Config {
{
FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal,
FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center,
FocusStrategyValue::Paged => ViewportFocusStrategy::Paged,
}
}
@@ -862,6 +892,17 @@ impl Config {
self.theme.surface.bar.opacity.unwrap_or(0.78),
"surface.bar.opacity",
)?,
inactive_dim: 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",
)?,
},
window_radius,
shadow_offset_y,
shadow_blur,
@@ -1576,6 +1617,11 @@ mod tests {
fn parses_scrolling_focus_strategy() {
let minimal = parse("");
let centered = parse("[scrolling]\nfocus_strategy = \"center_on_focus\"");
let paged = parse("[scrolling]\nfocus_strategy = \"paged\"");
assert_eq!(
paged.scrolling_focus_strategy(),
ViewportFocusStrategy::Paged
);
assert_eq!(
minimal.scrolling_focus_strategy(),
@@ -1659,6 +1705,11 @@ mod tests {
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
panel_opacity: 0.78,
inactive_dim: InactiveDimSettings {
enabled: false,
amount: 0.15,
duration_ms: 150.0
},
window_radius: 12.0,
shadow_offset_y: -2.0,
shadow_blur: 24.0,
@@ -1739,6 +1790,35 @@ mod tests {
}
}
#[test]
fn inactive_dimming_is_opt_in_and_configurable() {
let defaults = parse("").theme_settings().unwrap().inactive_dim;
assert!(!defaults.enabled);
assert_eq!(defaults.amount, 0.15);
assert_eq!(defaults.duration_ms, 150.0);
let settings =
parse("[appearance.inactive_dim]\nenabled = true\namount = 0.25\nduration_ms = 100")
.theme_settings()
.unwrap()
.inactive_dim;
assert!(settings.enabled);
assert_eq!(settings.amount, 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"),
] {
assert!(
parse(&format!("[appearance.inactive_dim]\n{key} = {value}"))
.theme_settings()
.is_err()
);
}
}
#[test]
fn parses_input_and_touchpad_settings() {
let config = parse(
+129
View File
@@ -0,0 +1,129 @@
use crate::config::InactiveDimSettings;
use ferese_layout::WindowId;
use std::time::Duration;
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(Debug)]
pub(crate) struct DimAnimation {
pub current: f64,
start: f64,
target: f64,
elapsed: f64,
}
impl DimAnimation {
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
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
}
+1
View File
@@ -3,6 +3,7 @@
mod backends;
mod config;
mod cursor;
mod dimming;
mod effects;
mod grabs;
mod handlers;
+65 -10
View File
@@ -9,7 +9,7 @@ use std::{
use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
use ferese_core::{
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId,
WorkspaceSet,
WorkspaceLayout, WorkspaceSet,
};
use ferese_layout::{
Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints,
@@ -119,8 +119,11 @@ pub struct Ferese {
pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>,
maximized_windows: HashSet<WindowId>,
maximized_column_widths: HashMap<WindowId, ColumnWidth>,
window_stack: crate::stacking::WindowStack,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dims: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dimming: HashMap<WindowId, crate::dimming::DimAnimation>,
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
@@ -451,8 +454,11 @@ impl Ferese {
window_ids: HashMap::new(),
window_geometry: HashMap::new(),
maximized_windows: HashSet::new(),
maximized_column_widths: HashMap::new(),
window_stack: crate::stacking::WindowStack::default(),
window_borders: HashMap::new(),
window_dims: HashMap::new(),
window_dimming: HashMap::new(),
window_shadows: HashMap::new(),
rounded_clip_programs: HashMap::new(),
overview_scrims: HashMap::new(),
@@ -911,8 +917,11 @@ impl Ferese {
self.space.unmap_elem(window);
self.window_geometry.remove(&id);
self.maximized_windows.remove(&id);
self.maximized_column_widths.remove(&id);
self.window_stack.remove(id);
self.window_borders.remove(&id);
self.window_dims.remove(&id);
self.window_dimming.remove(&id);
self.window_shadows.remove(&id);
self.closing_windows.remove(&id);
self.window_rules_applied.remove(&id);
@@ -954,6 +963,7 @@ impl Ferese {
continue;
};
let workspace_fullscreen = workspace.fullscreen;
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
let workspace_focus = workspace.last_focused;
let focused = self
.focused_window
@@ -1011,7 +1021,7 @@ impl Ferese {
self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id);
let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds
} else if is_maximized {
} else if is_maximized && !is_scrolling_layout {
maximized_rect(bounds, self.gap_config.outer)
} else {
match self.workspaces.placement(*id) {
@@ -1032,13 +1042,14 @@ impl Ferese {
);
visible.insert(*id);
let scrolling = if !is_fullscreen && !is_maximized && !is_floating {
viewport_target
.zip(viewport_current)
.map(|(target, current)| (workspace_id, rect.x + target, current))
} else {
None
};
let scrolling =
if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) {
viewport_target
.zip(viewport_current)
.map(|(target, current)| (workspace_id, rect.x + target, current))
} else {
None
};
placements.push((
window.clone(),
@@ -1241,6 +1252,29 @@ impl Ferese {
.collect::<Vec<_>>();
let mut active_animation = false;
let dim_settings = self.theme_settings.inactive_dim;
let duration = if self.animations_enabled {
dim_settings.duration_ms
} else {
0.0
};
let mut dim_changed = false;
for (_, id) in &windows {
let target = crate::dimming::target(
dim_settings,
self.focused_window,
*id,
self.overview.is_presenting(),
);
let dim = self
.window_dimming
.entry(*id)
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
let previous = dim.current;
active_animation |= dim.advance(target, delta, duration);
dim_changed |= previous != dim.current;
}
let mut ready_to_close = Vec::new();
for (id, animation) in &mut self.closing_windows {
if animation.advance(delta) {
@@ -1346,7 +1380,7 @@ impl Ferese {
.advance(delta, self.spring_config, self.animations_enabled);
self.sync_window_stacking();
if active_animation {
if active_animation || dim_changed {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
}
active_animation
@@ -1645,6 +1679,27 @@ impl Ferese {
} else {
self.maximized_windows.remove(&window);
}
if self.workspaces.placement(window) == Some(WindowPlacement::Tiled) {
let workspace_id = self.workspaces.workspace_for_window(window).unwrap();
if let Some(workspace) = self.workspaces.workspace_mut(workspace_id) {
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
if enabled {
if let Some(column) = layout
.columns()
.iter()
.find(|column| column.windows.contains(&window))
{
self.maximized_column_widths
.entry(window)
.or_insert(column.width);
}
let _ = layout.set_column_width(window, ColumnWidth::Proportion(1.0));
} else if let Some(width) = self.maximized_column_widths.remove(&window) {
let _ = layout.set_column_width(window, width);
}
}
}
}
self.relayout();
}
+82
View File
@@ -913,6 +913,22 @@ fn output_elements(
for (window, id, visual, decoration_progress, close_alpha) in windows {
let constrain = rounded_visual_rect(visual, output_geometry.loc);
let dim = state.window_dimming.get(&id).map_or(0.0, |dim| dim.current);
if let Some(overlay) = window_dim_element(
state,
renderer,
id,
constrain,
scale,
state.theme_settings.window_radius * decoration_progress,
dim as f32 * close_alpha,
output,
) {
// Front-to-back: dim the application and its border, not its shadow
// or other windows/layer surfaces. The overlay is input-transparent.
elements.push(overlay.into());
}
if let Some(programs) = rounded_clip_program.clone() {
let window_radius = state.theme_settings.window_radius * decoration_progress;
let shadow_offset_y = state.theme_settings.shadow_offset_y;
@@ -1163,6 +1179,72 @@ fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'static>> {
]
}
#[allow(clippy::too_many_arguments)]
fn window_dim_element(
state: &mut Ferese,
renderer: &mut GlesRenderer,
id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>,
scale: f64,
radius: f64,
amount: f32,
output: &Output,
) -> Option<PixelShaderElement> {
if amount <= 0.0 {
state.window_dims.remove(&id);
return None;
}
let mode = output.current_mode()?;
let program = material_program(state, renderer)?;
let parameters = BorderParameters {
geometry,
clip_rect: framebuffer_clip_rect(
geometry.to_physical_precise_round(scale),
mode.size,
output.current_transform().invert(),
),
radius: scaled_effect_value(radius, geometry, scale),
width: 0.0,
color: [0.0, 0.0, 0.0, amount.clamp(0.0, 1.0)],
};
let uniforms = |p: &BorderParameters| {
vec![
Uniform::new("visible_rect", p.clip_rect).into_owned(),
Uniform::new("material_radius", p.radius).into_owned(),
Uniform::new("tint", p.color).into_owned(),
Uniform::new("paint_mode", 0.0_f32).into_owned(),
Uniform::new("shadow_rect", p.clip_rect).into_owned(),
Uniform::new("shadow_values", [0.0_f32; 2]).into_owned(),
]
};
let context = renderer.context_id().erased();
let buffers = state.window_dims.entry(id).or_default();
let cached = buffers
.contexts
.entry(context)
.or_insert_with(|| CachedBorder {
element: PixelShaderElement::new(
program.0,
geometry,
None,
1.0,
uniforms(&parameters),
RenderElementKind::Unspecified,
),
parameters: parameters.clone(),
});
if cached.parameters != parameters {
if cached.parameters.geometry != geometry {
cached.element.resize(geometry, None);
}
// Updating uniforms advances the element's commit so unchanged client
// buffers still repaint while focus dimming animates.
cached.element.update_uniforms(uniforms(&parameters));
cached.parameters = parameters;
}
Some(cached.element.clone())
}
#[allow(clippy::too_many_arguments)]
fn window_shadow_element(
state: &mut Ferese,