Add window blur, organize Settings, and reduce launcher memory

This commit is contained in:
Samuel Onoja committed 2026-10-14 01:01:21 +01:00
1 parent bbc2f226d8
commit 07a8e50f47
17 files changed
+260 -88

No files matched your search

+4
View File
@@ -5,6 +5,7 @@ impl Config {
let effect = &self.appearance.focus_effect;
Ok(FocusEffectSettings {
enabled: effect.enabled,
blur: effect.blur,
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")?,
@@ -254,6 +255,7 @@ pub(super) struct AppearanceConfig {
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FocusEffectSettings {
pub enabled: bool,
pub blur: bool,
pub active_opacity: f64,
pub inactive_opacity: f64,
pub inactive_dim: f64,
@@ -264,6 +266,7 @@ pub struct FocusEffectSettings {
#[serde(default, deny_unknown_fields)]
pub(super) struct FocusEffectConfig {
pub(super) enabled: bool,
pub(super) blur: bool,
pub(super) active_opacity: f64,
pub(super) inactive_opacity: f64,
pub(super) inactive_dim: f64,
@@ -274,6 +277,7 @@ impl Default for FocusEffectConfig {
fn default() -> Self {
Self {
enabled: true,
blur: true,
active_opacity: 1.0,
inactive_opacity: 1.0,
inactive_dim: 0.0,
+1
View File
@@ -100,6 +100,7 @@ mod tests {
fn settings() -> FocusEffectSettings {
FocusEffectSettings {
enabled: true,
blur: true,
active_opacity: 1.0,
inactive_opacity: 0.8,
inactive_dim: 0.2,
+25 -2
View File
@@ -593,7 +593,8 @@ impl Ferese {
}
return;
}
if event.state() == ButtonState::Released && self.tab_buttons.remove(&event.button_code()) {
if event.state() == ButtonState::Released && self.consumed_pointer_buttons.remove(&event.button_code())
{
return;
}
let pointer = self.seat.get_pointer().expect("seat has a pointer");
@@ -626,11 +627,33 @@ impl Ferese {
return;
}
if event.state() == ButtonState::Pressed
&& !pointer.is_grabbed()
&& let Some(surface) = self.seat.get_keyboard().and_then(|keyboard| keyboard.current_focus())
&& self
.space
.layer_for_surface(&surface, smithay::desktop::WindowSurfaceType::ALL)
.is_some_and(|layer| layer.namespace() == "ferese-launcher")
&& !self
.layer_under(pointer.current_location())
.is_some_and(|(layer, _, _)| layer.wl_surface() == &surface)
&& let Some(shell) = self
.shell_resources
.iter()
.filter_map(|shell| shell.upgrade().ok())
.find(|shell| shell.version() >= 10)
{
// Consume both halves of the click before underlying windows or tabs see it.
self.consumed_pointer_buttons.insert(event.button_code());
shell.toggle_launcher(String::new());
return;
}
if event.state() == ButtonState::Pressed
&& !pointer.is_grabbed()
&& let Some(id) = self.tab_at(pointer.current_location())
{
self.tab_buttons.insert(event.button_code());
self.consumed_pointer_buttons.insert(event.button_code());
if event.button_code() == BTN_LEFT {
self.activate_managed_window(id);
}
+6
View File
@@ -195,6 +195,12 @@ pub(super) fn grouped_elements(
{
elements.push(material);
}
if window.material.is_none()
&& let Some(blur) =
super::window_blur::window_blur_element(state, renderer, output, window.presentation, corners)
{
elements.push(blur);
}
let theme = &state.theme_settings;
let (offset_factor, blur_factor, opacity_factor) = window.presentation.shadow_factors();
if let Some(shadow) = window_shadow_element(
+1
View File
@@ -1,6 +1,7 @@
mod backdrop;
mod capture;
mod closing;
mod window_blur;
mod window_content;
pub(crate) use closing::ClosedWindow;
#[cfg(test)]
+4
View File
@@ -20,6 +20,7 @@ pub(super) struct WindowBuffers {
pub borders: WindowBorderBuffers,
pub dim: WindowBorderBuffers,
pub resize_fill: WindowBorderBuffers,
pub blur: HashMap<(ErasedContextId, OutputId), super::window_blur::WindowBlur>,
pub shadow: WindowShadowBuffers,
corner_shape: Option<CornerShape>,
thumbnail_shapes: HashMap<OutputId, CornerShape>,
@@ -48,6 +49,7 @@ impl RenderResources {
buffers.dim.contexts.clear();
buffers.resize_fill.contexts.clear();
buffers.shadow.contexts.clear();
buffers.blur.clear();
if let Some(snapshot) = &mut buffers.snapshot {
snapshot.commit.increment();
@@ -101,6 +103,7 @@ impl RenderResources {
for window in self.windows.values_mut() {
window.thumbnail_shapes.remove(&id);
window.blur.retain(|(_, output), _| *output != id);
}
for surface in self.surfaces.values_mut() {
@@ -167,6 +170,7 @@ impl RenderResources {
window.dim.contexts.remove(context);
window.resize_fill.contexts.remove(context);
window.shadow.contexts.remove(context);
window.blur.retain(|(key, _), _| key != context);
if window
.snapshot
+6
View File
@@ -340,6 +340,12 @@ fn scene_elements(
{
elements.push(background);
}
if material_surface.is_none()
&& let Some(blur) =
super::window_blur::window_blur_element(state, renderer, output, sample.presentation, corners)
{
elements.push(blur);
}
if let Some(shadow) = shadow {
elements.push(shadow.into());
}
+101
View File
@@ -0,0 +1,101 @@
use super::*;
#[derive(Debug)]
pub(super) struct WindowBlur {
parameters: MaterialParameters,
element: BlurRenderElement,
}
pub(super) fn window_blur_element(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
presentation: crate::presentation::WindowPresentation,
corners: RoundedRect,
) -> Option<AnimatedWindowRenderElement> {
let radius = state.theme_settings.backdrop_blur;
if !state.focus_effect.enabled
|| !state.focus_effect.blur
|| state.theme_settings.reduce_transparency
|| radius <= 0.0
|| presentation.focus_alpha <= 0.0
|| presentation.focus_alpha >= 1.0
|| presentation.alpha() <= 0.0
{
if let Some(window) = state.render.windows.get_mut(&presentation.id) {
window.blur.clear();
}
return None;
}
let mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale();
let transform = output.current_transform().invert();
let output_size = output
.current_transform()
.transform_size(mode.size)
.to_f64()
.to_logical(scale)
.to_i32_ceil();
let geometry = corners.rect.to_f64().to_logical(scale).to_i32_up();
let Some(visible) = geometry.intersection(Rectangle::from_size(output_size)) else {
if let Some(window) = state.render.windows.get_mut(&presentation.id) {
window.blur.clear();
}
return None;
};
let sample_geometry = expanded_blur_region(visible, radius.ceil() as i32, output_size);
let sample_physical = sample_geometry.to_physical_precise_round(scale);
let parameters = MaterialParameters {
panel: None,
corner_radii: [corners.radius; 4],
// Replace the sharp backdrop completely. Window content supplies the
// transparency; only the lifecycle animation fades this background.
presentation_alpha: presentation.opacity.current.clamp(0.0, 1.0) as f32,
background_opacity: 0.0,
blur: (radius * scale) as f32,
sample_geometry,
sample_physical,
sample_framebuffer: framebuffer_clip_rect(sample_physical, mode.size, transform),
shadow_rect: [0.0; 4],
shadow_values: [0.0; 2],
shadow_bounds: geometry,
geometry,
tint: [0.0; 4],
generation: 0,
opaque: false,
visible_framebuffer: framebuffer_clip_rect(corners.rect, mode.size, transform),
};
let key = (renderer.context_id().erased(), state.output_id(output)?);
let program = blur_program(&mut state.render, renderer)?;
let buffers = &mut state.render.windows.entry(presentation.id).or_default().blur;
let capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer);
let capture_size = Size::from((capture_rect[2], capture_rect[3]));
if buffers
.get(&key)
.is_none_or(|cached| cached.element.texture.size() != capture_size)
{
let texture = match Offscreen::<GlesTexture>::create_buffer(renderer, Fourcc::Abgr8888, capture_size) {
Ok(texture) => texture,
Err(error) => {
tracing::warn!(%error, "window blur allocation failed");
buffers.remove(&key);
return None;
}
};
let element = BlurRenderElement::new(texture, program.0, &parameters, Arc::new(AtomicBool::new(true)));
buffers.insert(
key.clone(),
WindowBlur {
parameters: parameters.clone(),
element,
},
);
}
let cached = buffers.get_mut(&key)?;
if cached.parameters != parameters {
cached.element.update(&parameters);
cached.parameters = parameters;
}
Some(cached.element.clone().into())
}
+2 -2
View File
@@ -467,7 +467,7 @@ pub struct Ferese {
pub(crate) tab_indicator: tab_indicator::TabIndicatorState,
pub(crate) tab_indicator_settings: crate::config::TabIndicatorSettings,
pub(crate) tab_bars: HashMap<WorkspaceId, Vec<ferese_layout::TabBar>>,
pub(crate) tab_buttons: HashSet<u32>,
pub(crate) consumed_pointer_buttons: HashSet<u32>,
pub(crate) focus_history: ferese_core::FocusHistory,
pub(crate) focus_cycle: Option<ferese_core::FocusCycle>,
column_width_presets: Vec<ColumnWidth>,
@@ -771,7 +771,7 @@ impl Ferese {
tab_indicator: tab_indicator::TabIndicatorState::default(),
tab_indicator_settings: config.tab_indicator,
tab_bars: HashMap::new(),
tab_buttons: HashSet::new(),
consumed_pointer_buttons: HashSet::new(),
focus_history: Default::default(),
focus_cycle: None,
column_width_presets: config.column_width_presets,
+1
View File
@@ -388,6 +388,7 @@ mod tests {
.unwrap();
state.focus_effect = crate::config::FocusEffectSettings {
enabled: true,
blur: true,
active_opacity: 0.9,
inactive_opacity: 0.6,
inactive_dim: 0.2,
+12 -2
View File
@@ -29,6 +29,7 @@ one appearance.
| `appearance.focus-effect.enabled` | boolean | `true` | Apply window opacity and dimming; disabling preserves the saved settings |
| `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.blur` | boolean | `true` | Blur behind translucent application windows using the shared blur radius |
| `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 |
@@ -47,6 +48,10 @@ opacity still follows activation; dimming fades out as overview opens. Fullscree
windows use the same opacity settings. Opening and closing fades multiply window
opacity. Reduced motion disables the focus transition.
**Window blur** in Settings controls blur behind both active and inactive windows.
It uses `theme.material.blur-radius` independently of the shell material style.
Fully opaque or invisible windows skip blur. Reduced transparency disables it.
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:
@@ -105,11 +110,16 @@ the automatic gradient. Inactive window borders derive their gradient from the
border color and keep its transparency.
Automatic accent endpoints are adjusted for contrast against the theme's surfaces.
In Settings → Appearance, choose **Gradient** or **Solid** for **Accent style**
and **Inactive border style**. In KDL, `theme.focus-ring.style` and
In Settings → Appearance, **Accent style** controls the gradient used by focus
borders and selected shell controls. Settings → Windows contains **Inactive
border style** and **Focus border**. Accent style and inactive border style
offer **Gradient** or **Solid**. In KDL, `theme.focus-ring.style` and
`theme.border.style` accept `"auto"` (default) or `"solid"`. Solid disables the
gradient, including any explicit endpoints, without deleting them from the config.
Shell surface style, opacity and corner radius are in Settings → Panel & Shell.
**Background blur** in Appearance sets the blur radius for shell surfaces and windows.
For custom endpoints, set `theme.focus-ring.gradient` or `theme.border.gradient`.
Both require `from` and `to` colors; `angle` is optional and defaults to `0`.
Angles are clockwise: 0 is left-to-right, 90 top-to-bottom. Explicit gradients
+1 -1
View File
@@ -12,7 +12,7 @@ environment for screenshots to work.
Text input is available by default; Ctrl+Alt+Escape releases an active shortcut
inhibitor. Use `feresectl --help` for runtime control commands.
Shell rounding is controlled in Settings → Appearance → Shell corner radius.
Shell rounding is controlled in Settings → Panel & Shell → Shell corner radius.
The shell and compositor use the same squircle profile for fills, borders, shadows
and blur masks. Small controls cap the radius to fit their size; circles and pills
keep circular outlines. The measured profile has small tangent and curvature
+1 -1
View File
@@ -255,7 +255,7 @@ Notifications or Controls view. Escape or the close button collapses it.
With an Activities item configured, running tasks can occupy the collapsed and
peek states, and the hub gains an Activities tab. **Visible activities** and
**Show activity count** in Appearance control its compact slots and `+n` badge;
**Show activity count** in Panel & Shell control its compact slots and `+n` badge;
see [Activities configuration](configuration/activities.md).
The Workspaces view shows miniature window layouts. Click a tile to switch
@@ -315,6 +315,20 @@ impl App {
.padding(12)
.class(crate::visuals::surface(palette.card, 14.)),
);
let mut shell = widget::column([])
.spacing(4)
.push(self.panel_heading("Shell appearance", 15.));
for field in crate::schema::fields(Page::Bar)
.into_iter()
.filter(|field| field.path.starts_with("theme."))
{
shell = shell.push(self.field(field));
}
rows = rows.push(
widget::container(shell)
.padding(12)
.class(crate::visuals::surface(palette.card, 14.)),
);
}
PanelPage::Arrange => return self.panel_workspace(),
}
+67 -61
View File
@@ -99,7 +99,7 @@ impl Page {
Self::Appearance => "A desktop that feels like yours.",
Self::Accessibility => "Make the desktop easier to see and use.",
Self::Wallpaper => "Set the scene for your workspace.",
Self::Bar => "Arrange panel items and groups.",
Self::Bar => "Arrange your panel and change the shell's appearance.",
Self::Notifications => "Stay informed on your terms.",
Self::LockScreen => "Your desktop, safely put away.",
Self::Windows => "Window layouts, spacing and focus effects.",
@@ -392,19 +392,6 @@ pub fn fields(page: Page) -> Vec<Field> {
"system follows your desktop timezone; or enter an IANA timezone.",
"system",
),
range(
"theme.geometry.shell_radius",
"Shell corner radius",
"Rounds the bar, menus, notifications and hover backgrounds. Window corners are separate.",
RangeSpec {
default: 14.0,
min: 0.0,
max: 32.0,
step: 1.0,
suffix: " px",
integer: false,
},
),
text(
"theme.accent",
"Accent color",
@@ -418,37 +405,10 @@ pub fn fields(page: Page) -> Vec<Field> {
"auto",
&[("auto", "Gradient"), ("solid", "Solid")],
),
choice(
"theme.border.style",
"Inactive border style",
"Gradients retain the border color's transparency.",
"auto",
&[("auto", "Gradient"), ("solid", "Solid")],
),
choice(
"theme.material.style",
"Surface style",
"Choose the finish for the bar and menus.",
"solid",
&[("solid", "Solid"), ("translucent", "Translucent")],
),
range(
"theme.material.opacity",
"Shell opacity",
"Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.",
RangeSpec {
default: ferese_config::DEFAULT_MATERIAL_OPACITY,
min: 0.0,
max: 1.0,
step: 0.01,
suffix: "",
integer: false,
},
),
range(
"theme.material.blur_radius",
"Background blur",
"Softness behind translucent surfaces.",
"Softness behind translucent shell surfaces and windows. Zero disables blur.",
RangeSpec {
default: 12.0,
min: 0.0,
@@ -464,19 +424,6 @@ pub fn fields(page: Page) -> Vec<Field> {
"Use the name of an installed font family.",
"Inter",
),
range(
"theme.geometry.focus_ring_width",
"Focus border",
"A subtle outline around the active window.",
RangeSpec {
default: 2.0,
min: 0.0,
max: 4.0,
step: 0.5,
suffix: " px",
integer: false,
},
),
],
Page::Wallpaper => vec![
text(
@@ -521,6 +468,39 @@ pub fn fields(page: Page) -> Vec<Field> {
),
],
Page::Bar => vec![
range(
"theme.geometry.shell_radius",
"Shell corner radius",
"Rounds the bar, menus, notifications and hover backgrounds. Window corners are separate.",
RangeSpec {
default: 14.0,
min: 0.0,
max: 32.0,
step: 1.0,
suffix: " px",
integer: false,
},
),
choice(
"theme.material.style",
"Surface style",
"Choose the finish for the panel, launcher, menus and notifications.",
"solid",
&[("solid", "Solid"), ("translucent", "Translucent")],
),
range(
"theme.material.opacity",
"Shell opacity",
"Background opacity for the bar, popovers, menus, notifications and sharing dialog. Text and icons stay opaque.",
RangeSpec {
default: ferese_config::DEFAULT_MATERIAL_OPACITY,
min: 0.0,
max: 1.0,
step: 0.01,
suffix: "",
integer: false,
},
),
range(
"panels.0.geometry.height",
"Height",
@@ -651,6 +631,32 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: false,
},
),
choice(
"theme.border.style",
"Inactive border style",
"Gradients retain the border color's transparency.",
"auto",
&[("auto", "Gradient"), ("solid", "Solid")],
),
range(
"theme.geometry.focus_ring_width",
"Focus border",
"A subtle outline around the active window.",
RangeSpec {
default: 2.0,
min: 0.0,
max: 4.0,
step: 0.5,
suffix: " px",
integer: false,
},
),
toggle(
"input.focus_follows_mouse",
"Focus follows pointer",
"Focus visible windows without raising or scrolling. Click to bring them into view.",
false,
),
toggle(
"appearance.focus_effect.enabled",
"Window focus effects",
@@ -683,6 +689,12 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: false,
},
),
toggle(
"appearance.focus_effect.blur",
"Window blur",
"Soften the background behind translucent windows. Adjust the amount in Appearance.",
true,
),
range(
"appearance.focus_effect.inactive_dim",
"Inactive window dimming",
@@ -770,12 +782,6 @@ pub fn fields(page: Page) -> Vec<Field> {
integer: true,
},
),
toggle(
"input.focus_follows_mouse",
"Focus follows pointer",
"Focus visible windows without raising or scrolling. Click to bring them into view.",
false,
),
toggle(
"input.touchpad.tap",
"Tap to click",
+13 -5
View File
@@ -33,7 +33,6 @@ pub(super) enum Message {
pub(super) struct Launcher {
pub id: window::Id,
pub(super) output: String,
top: f32,
input: cosmic::widget::Id,
query: String,
category: Option<Category>,
@@ -138,7 +137,13 @@ impl FereseShell {
};
let name = output.name.clone().unwrap_or(name);
let wl_output = output.output.clone();
let top = output.size.map_or(240., |(_, h)| h as f32 * 0.3);
let (output_width, output_height) = output.size.unwrap_or((1920, 1080));
// Reserve the largest browse view once, avoiding buffer reallocations during springs.
let width = 720u32.min(output_width.max(1) as u32);
let height = 404u32.min((output_height - 32).max(1) as u32);
let top = ((output_height as f32 * 0.3) as i32)
.min(output_height - height as i32 - 16)
.max(0);
let dismissed = Task::batch([self.destroy_menu(), self.destroy_system_modal(true)]);
let id = window::Id::unique();
let mut progress = AnimatedValue::new(0.);
@@ -151,7 +156,6 @@ impl FereseShell {
self.launcher = Some(Launcher {
id,
output: name,
top,
input: cosmic::widget::Id::unique(),
query: String::new(),
category: None,
@@ -191,11 +195,15 @@ impl FereseShell {
id,
layer: Layer::Overlay,
keyboard_interactivity: KeyboardInteractivity::Exclusive,
anchor: Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT,
anchor: Anchor::TOP,
output: IcedOutput::Output(wl_output.clone()),
namespace: "ferese-launcher".into(),
exclusive_zone: -1,
size: Some((None, None)),
size: Some((Some(width), Some(height))),
margin: IcedMargin {
top,
..Default::default()
},
..Default::default()
},
Some(Box::new(move |app| app.view_launcher(id))),
+1 -14
View File
@@ -484,15 +484,6 @@ impl FereseShell {
}
body = body.push(container(rows).padding([0, 12]));
}
let available_height = self
.outputs
.iter()
.find(|o| o.name.as_deref() == Some(&launcher.output))
.and_then(|o| o.size)
.map(|(_, h)| h as f32);
let top = available_height.map_or(launcher.top, |h| {
(h * 0.3).min(h - launcher.height.current as f32 - 16.).max(16.)
});
let mut background = color_with_opacity(self.config.theme.surface_popover, opacity);
if self.config.theme.high_contrast || self.config.theme.reduce_transparency {
background.a = opacity;
@@ -521,11 +512,7 @@ impl FereseShell {
launcher.regions.clone(),
))
.width(Length::Fill)
.align_x(cosmic::iced::Alignment::Center)
.padding(cosmic::iced::Padding {
top,
..Default::default()
});
.align_x(cosmic::iced::Alignment::Center);
let dismiss = message(Message::Dismiss(id));
let backdrop = mouse_area(Space::new().width(Length::Fill).height(Length::Fill))
.on_press(dismiss.clone())