Refine shell status controls and glass rendering; defer cursor redraw safely

This commit is contained in:
Samuel Onoja committed 2026-09-26 05:05:50 +01:00
1 parent e85fc1e032
commit b3fa82b53b
53 files changed
+3329 -259

No files matched your search

+32 -4
View File
@@ -111,6 +111,31 @@ opacity = 0.20
style = "glass" # glass, translucent, or solid
```
Glass roles use live backdrop sampling, a dark tint, diagonal lighting, inset
edges, static overlay grain, and role-specific shadows. Configure them with:
```toml
[appearance]
corner_radius = 14.0
reduced_effects = false # true selects reduced_blur or a stricter quality tier
[appearance.glass]
tint_color = "#1C202E"
blur_scale = 1.0 # clamped to 0.5–1.5; non-finite values are rejected
grain = true
quality = "full"
```
Quality tiers are `full`, `no_grain`, `no_saturation`, `reduced_blur`
(at most 12 logical pixels with fewer samples), `translucent`, and `solid`.
Each tier retains earlier reductions without reducing tint opacity or changing
layout. Backdrop shader or texture allocation failure falls back to translucent.
The legacy `surface.panel_elevated` role uses the panel tint with popover elevation.
Shell shadows use restrained logical-pixel presets: panel `1/5/4%`, popover
and menu `2/8/7%`, HUD `1/4/4%`, and notification/modal `3/10/9%`
(vertical offset / blur / opacity). Private effects capabilities
remain required; public background-effect requests cannot select these materials.
Fullscreen windows are borderless and shadowless. Other managed windows use GPU
rounded clipping, matching rounded focus rings, and an analytic soft shadow.
These effects overlay or surround the allocation without adding client padding.
@@ -130,7 +155,7 @@ their client surfaces, `Super+H/J/K/L` moves the selection using the presented
preview geometry, and clicking a preview activates it without forwarding the
selection click to the application.
The first production shell slice provides a floating libcosmic top bar backed
The shell provides an edge-to-edge libcosmic menu bar backed
by the private shell-control connection. Build and preview it nested with:
```bash
@@ -139,9 +164,12 @@ target/debug/ferese --backend nested --grant-effects --grant-shell-control -- \
target/debug/ferese-shell
```
The Ferese mark enters overview, workspace pills switch workspaces, the clock
remains geometrically centered, and focused-application/output metadata updates
from compositor snapshots.
The Ferese mark enters overview, where workspace indicators become available.
The focused app appears on the left; status controls and day/time sit on the
right. The 28px bar has square screen edges, 19px icons, a fine bottom separator,
and no drop shadow. An 8px gap separates it from windows; adjust
`top_bar_window_gap` under `[theme.geometry]` to change it. This is additional to
the workspace outer gap. Its dark startup fallback matches the material palette.
Keyboard and direct-session touchpad settings are applied at startup:
+111
View File
@@ -25,6 +25,8 @@ pub struct Config {
#[serde(default)]
theme: ThemeConfig,
#[serde(default)]
appearance: AppearanceConfig,
#[serde(default)]
commands: HashMap<String, Vec<String>>,
#[serde(default)]
bindings: Vec<BindingConfig>,
@@ -116,6 +118,9 @@ pub struct ThemeSettings {
pub shadow_blur: f64,
pub shadow_opacity: f64,
pub material_style: MaterialStyle,
pub glass: GlassSettings,
pub material_radius: f64,
pub panel_radius: f64,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
@@ -127,6 +132,56 @@ pub enum MaterialStyle {
Solid,
}
/// Ordered quality tiers; each step retains the preceding reductions.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
#[serde(rename_all = "snake_case")]
pub enum GlassQuality {
#[default]
Full,
NoGrain,
NoSaturation,
ReducedBlur,
Translucent,
Solid,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlassSettings {
pub tint: RgbaColor,
pub blur_scale: f32,
pub grain: bool,
pub quality: GlassQuality,
}
impl Default for GlassSettings {
fn default() -> Self {
Self {
tint: RgbaColor([28.0 / 255.0, 32.0 / 255.0, 46.0 / 255.0, 1.0]),
blur_scale: 1.0,
grain: true,
quality: GlassQuality::Full,
}
}
}
#[derive(Debug, Default, Deserialize)]
struct AppearanceConfig {
corner_radius: Option<f64>,
#[serde(default)]
reduced_effects: bool,
#[serde(default)]
glass: GlassConfig,
}
#[derive(Debug, Default, Deserialize)]
struct GlassConfig {
tint_color: Option<String>,
blur_scale: Option<f64>,
grain: Option<bool>,
#[serde(default)]
quality: GlassQuality,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeConfig {
#[serde(default)]
@@ -202,6 +257,8 @@ struct ThemeGeometryConfig {
focus_ring_width: f64,
#[serde(default = "default_window_radius")]
window_radius: f64,
#[serde(default)]
top_bar_radius: f64,
}
impl Default for ThemeGeometryConfig {
@@ -210,6 +267,7 @@ impl Default for ThemeGeometryConfig {
border_width: default_border_width(),
focus_ring_width: default_focus_ring_width(),
window_radius: default_window_radius(),
top_bar_radius: 0.0,
}
}
}
@@ -803,6 +861,26 @@ impl Config {
let shadow_opacity =
unit_theme_value(self.theme.shadow.soft.opacity, "shadow.soft.opacity")?;
let mut glass = GlassSettings::default();
if let Some(tint) = &self.appearance.glass.tint_color {
glass.tint = parse_color(tint, "appearance.glass.tint_color")?;
// Opacity belongs to the semantic role, never the color token.
glass.tint.0[3] = 1.0;
}
glass.blur_scale = finite_theme_value(
self.appearance.glass.blur_scale.unwrap_or(1.0),
"appearance.glass.blur_scale",
)?
.clamp(0.5, 1.5) as f32;
glass.grain = self.appearance.glass.grain.unwrap_or(true);
glass.quality = self.appearance.glass.quality;
if self.appearance.reduced_effects {
glass.quality = glass.quality.max(GlassQuality::ReducedBlur);
}
let material_radius = nonnegative_theme_value(
self.appearance.corner_radius.unwrap_or(14.0),
"appearance.corner_radius",
)?;
Ok(ThemeSettings {
border_width,
focus_ring_width,
@@ -818,6 +896,12 @@ impl Config {
shadow_blur,
shadow_opacity,
material_style: self.theme.material.style,
glass,
material_radius,
panel_radius: nonnegative_theme_value(
self.theme.geometry.top_bar_radius,
"geometry.top_bar_radius",
)?,
})
}
@@ -1581,6 +1665,30 @@ mod tests {
assert!(invalid.gap_config().is_err());
}
#[test]
fn glass_configuration_clamps_cost_and_rejects_nonfinite_values() {
for (value, expected) in [(0.1, 0.5), (1.2, 1.2), (100.0, 1.5)] {
let config = parse(&format!("[appearance.glass]\nblur_scale = {value}"));
assert_eq!(config.theme_settings().unwrap().glass.blur_scale, expected);
}
for value in ["nan", "inf", "-inf"] {
assert!(
parse(&format!("[appearance.glass]\nblur_scale = {value}"))
.theme_settings()
.is_err()
);
}
let settings = parse("[appearance]\nreduced_effects = true\ncorner_radius = 17.0\n[appearance.glass]\ntint_color = \"#123456\"\ngrain = false")
.theme_settings().unwrap();
assert_eq!(settings.glass.quality, GlassQuality::ReducedBlur);
assert!(!settings.glass.grain);
assert_eq!(settings.material_radius, 17.0);
assert_eq!(
settings.glass.tint,
RgbaColor([18.0 / 255.0, 52.0 / 255.0, 86.0 / 255.0, 1.0])
);
}
#[test]
fn parses_and_validates_theme_window_tokens() {
let configured = parse(
@@ -1604,6 +1712,9 @@ mod tests {
shadow_blur: 24.0,
shadow_opacity: 0.4,
material_style: MaterialStyle::Translucent,
glass: GlassSettings::default(),
material_radius: 14.0,
panel_radius: 0.0,
}
);
assert!(invalid_color.theme_settings().is_err());
+165 -45
View File
@@ -16,7 +16,10 @@ use smithay::{
};
use crate::{
Ferese, config::MaterialStyle, private_client::ClientCapabilities, state::ClientState,
Ferese,
config::{GlassQuality, GlassSettings, MaterialStyle},
private_client::ClientCapabilities,
state::ClientState,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -38,47 +41,72 @@ pub(crate) struct ResolvedMaterial {
pub saturation: f32,
pub brightness: f32,
pub noise: f32,
pub shadow: [f64; 3],
}
pub(crate) fn resolve_material(role: SemanticRole, style: MaterialStyle) -> ResolvedMaterial {
let (base_opacity, blur, saturation, brightness, noise) = match role {
SemanticRole::Panel => (0.78, 24.0, 1.08, 1.02, 0.012),
SemanticRole::PanelElevated
| SemanticRole::Popover
| SemanticRole::Menu
| SemanticRole::Notification => (0.84, 28.0, 1.10, 1.03, 0.014),
SemanticRole::Hud | SemanticRole::Modal => (0.88, 30.0, 1.06, 1.04, 0.010),
pub(crate) fn resolve_material(
role: SemanticRole,
style: MaterialStyle,
settings: GlassSettings,
) -> ResolvedMaterial {
// Restrained shell shadows in logical pixels: the full-width bar needs
// less elevation than popovers, while dialogs retain a little more depth.
let (opacity, blur, saturation, shadow) = match role {
SemanticRole::Panel => (0.55, 24.0, 1.35, [1.0, 5.0, 0.04]),
SemanticRole::PanelElevated => (0.55, 24.0, 1.35, [2.0, 8.0, 0.07]),
SemanticRole::Popover => (0.55, 22.0, 1.35, [2.0, 8.0, 0.07]),
SemanticRole::Menu => (0.60, 20.0, 1.30, [2.0, 8.0, 0.07]),
SemanticRole::Hud => (0.65, 18.0, 1.25, [1.0, 4.0, 0.04]),
SemanticRole::Notification => (0.58, 24.0, 1.35, [3.0, 10.0, 0.09]),
SemanticRole::Modal => (0.62, 28.0, 1.40, [3.0, 10.0, 0.09]),
};
match style {
MaterialStyle::Glass => ResolvedMaterial {
style,
// Glass already contains an opaque copy of the backdrop. Its tint
// therefore needs less coverage than the no-capture translucent
// profile or the blur becomes visually indistinguishable from a
// flat alpha surface.
opacity: base_opacity * 0.72,
blur,
saturation,
brightness,
noise,
// Preserve the separately defined translucent family when selected
// directly. Glass degradation keeps the glass role's contrast floor.
let opacity = if style == MaterialStyle::Translucent {
match role {
SemanticRole::Panel => 0.78,
SemanticRole::Hud | SemanticRole::Modal => 0.88,
_ => 0.84,
}
} else {
opacity
};
let style = if style == MaterialStyle::Glass {
match settings.quality {
GlassQuality::Solid => MaterialStyle::Solid,
GlassQuality::Translucent => MaterialStyle::Translucent,
_ => style,
}
} else {
style
};
let glass = style == MaterialStyle::Glass;
ResolvedMaterial {
style,
opacity: if style == MaterialStyle::Solid {
1.0
} else {
opacity
},
MaterialStyle::Translucent => ResolvedMaterial {
style,
opacity: base_opacity,
blur: 0.0,
saturation: 1.0,
brightness: 1.0,
noise: 0.0,
blur: if !glass {
0.0
} else if settings.quality >= GlassQuality::ReducedBlur {
(blur * settings.blur_scale).min(12.0)
} else {
blur * settings.blur_scale
},
MaterialStyle::Solid => ResolvedMaterial {
style,
opacity: 1.0,
blur: 0.0,
saturation: 1.0,
brightness: 1.0,
noise: 0.0,
saturation: if glass && settings.quality < GlassQuality::NoSaturation {
saturation
} else {
1.0
},
brightness: 1.0,
noise: if glass && settings.grain && settings.quality < GlassQuality::NoGrain {
0.05
} else {
0.0
},
shadow,
}
}
@@ -297,13 +325,25 @@ mod tests {
#[test]
fn styles_preserve_semantics_but_disable_unsupported_effects() {
let glass = resolve_material(SemanticRole::Popover, MaterialStyle::Glass);
let translucent = resolve_material(SemanticRole::Popover, MaterialStyle::Translucent);
let solid = resolve_material(SemanticRole::Popover, MaterialStyle::Solid);
let glass = resolve_material(
SemanticRole::Popover,
MaterialStyle::Glass,
GlassSettings::default(),
);
let translucent = resolve_material(
SemanticRole::Popover,
MaterialStyle::Translucent,
GlassSettings::default(),
);
let solid = resolve_material(
SemanticRole::Popover,
MaterialStyle::Solid,
GlassSettings::default(),
);
assert!(glass.blur > 0.0);
assert!(glass.noise > 0.0);
assert!(glass.opacity < translucent.opacity);
assert!(glass.opacity <= translucent.opacity);
assert_eq!(translucent.blur, 0.0);
assert_eq!(translucent.noise, 0.0);
assert_eq!(solid.opacity, 1.0);
@@ -311,11 +351,91 @@ mod tests {
}
#[test]
fn elevated_roles_resolve_more_strongly_than_the_panel() {
let panel = resolve_material(SemanticRole::Panel, MaterialStyle::Glass);
let popover = resolve_material(SemanticRole::Popover, MaterialStyle::Glass);
fn glass_presets_use_role_specific_tints_and_restrained_shadows() {
for (role, blur, saturation, opacity, shadow) in [
(SemanticRole::Panel, 24.0, 1.35, 0.55, [1.0, 5.0, 0.04]),
(SemanticRole::Popover, 22.0, 1.35, 0.55, [2.0, 8.0, 0.07]),
(SemanticRole::Menu, 20.0, 1.30, 0.60, [2.0, 8.0, 0.07]),
(SemanticRole::Hud, 18.0, 1.25, 0.65, [1.0, 4.0, 0.04]),
(
SemanticRole::Notification,
24.0,
1.35,
0.58,
[3.0, 10.0, 0.09],
),
(SemanticRole::Modal, 28.0, 1.40, 0.62, [3.0, 10.0, 0.09]),
] {
let material = resolve_material(role, MaterialStyle::Glass, GlassSettings::default());
assert_eq!(
(
material.blur,
material.saturation,
material.opacity,
material.shadow
),
(blur, saturation, opacity, shadow)
);
}
}
assert!(popover.opacity > panel.opacity);
assert!(popover.blur > panel.blur);
#[test]
fn degradation_is_ordered_and_preserves_contrast_and_elevation() {
let reference = resolve_material(
SemanticRole::Modal,
MaterialStyle::Glass,
GlassSettings::default(),
);
for quality in [
GlassQuality::Full,
GlassQuality::NoGrain,
GlassQuality::NoSaturation,
GlassQuality::ReducedBlur,
GlassQuality::Translucent,
GlassQuality::Solid,
] {
let material = resolve_material(
SemanticRole::Modal,
MaterialStyle::Glass,
GlassSettings {
quality,
..GlassSettings::default()
},
);
assert!(material.opacity >= reference.opacity);
assert_eq!(material.shadow, reference.shadow);
if quality >= GlassQuality::NoGrain {
assert_eq!(material.noise, 0.0);
}
if quality >= GlassQuality::NoSaturation {
assert_eq!(material.saturation, 1.0);
}
if quality >= GlassQuality::ReducedBlur {
assert!(material.blur <= 12.0);
}
if quality >= GlassQuality::Translucent {
assert_eq!(material.blur, 0.0);
}
if quality == GlassQuality::Solid {
assert_eq!(material.opacity, 1.0);
}
}
}
#[test]
fn popover_uses_the_same_tint_with_a_smaller_blur_than_panel() {
let panel = resolve_material(
SemanticRole::Panel,
MaterialStyle::Glass,
GlassSettings::default(),
);
let popover = resolve_material(
SemanticRole::Popover,
MaterialStyle::Glass,
GlassSettings::default(),
);
assert_eq!(popover.opacity, panel.opacity);
assert!(popover.blur < panel.blur);
}
}
+4
View File
@@ -63,6 +63,10 @@ impl CompositorHandler for Ferese {
}
fn destroyed(&mut self, surface: &WlSurface) {
// A removed popup changes the backdrop even without a final buffer
// commit. Invalidate expanded sampling regions as well as its bounds.
self.invalidate_material_scene();
crate::backends::direct::render_all(self);
if self.idle_inhibitors.remove(surface).is_some() {
self.idle_notifier_state
.set_is_inhibited(!self.idle_inhibitors.is_empty());
+4 -1
View File
@@ -59,7 +59,10 @@ impl SeatHandler for Ferese {
.or_insert_with(|| crate::cursor::load_named_cursor(&self.cursor_theme, *icon));
}
self.cursor_status = image;
crate::backends::direct::render_all(self);
// Pointer focus transitions invoke this with the pointer lock held.
// render_all -> cursor_elements -> current_location would try to
// acquire that same lock, freezing the entire compositor thread.
self.cursor_redraw_pending = true;
}
fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) {
+4
View File
@@ -272,6 +272,10 @@ impl XdgShellHandler for Ferese {
}
if let Some(keyboard) = seat.get_keyboard() {
keyboard.set_grab(self, PopupKeyboardGrab::new(&popup_grab), serial);
// Focus the popup immediately, rather than waiting for its first
// key event. Shell clients use focus to initialize popup effects,
// and Escape must be delivered to the popup from the outset.
keyboard.set_focus(self, popup_grab.current_grab(), serial);
}
}
}
+81 -1
View File
@@ -77,7 +77,19 @@ fn main() -> Result<(), Box<dyn Error>> {
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities);
let result = event_loop.run(None, &mut state, |_| {});
let result = event_loop.run(None, &mut state, |state| {
// All input/Wayland callbacks have returned, releasing seat locks.
// Coalesce cursor changes and redraw here, never inside cursor_image.
if std::mem::take(&mut state.cursor_redraw_pending) {
backends::direct::render_all(state);
}
// Registry/sync/configure replies must not depend on a submitted
// frame: at startup clients need these before they can draw anything.
// This also keeps idle DRM sessions responsive when there is no damage.
if let Err(error) = state.display_handle.flush_clients() {
tracing::warn!(%error, "failed to flush Wayland clients");
}
});
if let Some(child) = &mut child {
terminate_child(child);
@@ -151,3 +163,71 @@ fn terminate_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
#[cfg(test)]
mod startup_tests {
use super::*;
use std::{
io::{Read, Write},
os::unix::net::UnixStream,
sync::Arc,
};
#[test]
fn deferred_cursor_redraw_runs_after_input_lock_is_released() {
use std::sync::Mutex;
let mut event_loop = EventLoop::<bool>::try_new().unwrap();
let pointer_lock = Arc::new(Mutex::new(()));
let callback_lock = pointer_lock.clone();
event_loop.handle().insert_idle(move |pending| {
// Mirrors Smithay's cursor_image callback during pointer motion.
let _guard = callback_lock.lock().unwrap();
*pending = true;
*pending = true; // Multiple cursor changes must coalesce.
assert!(callback_lock.try_lock().is_err());
});
let signal = event_loop.get_signal();
let mut pending = false;
let mut redraws = 0;
event_loop
.run(Some(Duration::ZERO), &mut pending, |pending| {
if std::mem::take(pending) {
// Rendering reads the pointer location at this point.
let _guard = pointer_lock.try_lock().expect("input lock still held");
redraws += 1;
}
assert!(!std::mem::take(pending));
signal.stop();
})
.unwrap();
assert_eq!(redraws, 1);
}
#[test]
fn wayland_roundtrip_completes_without_a_rendered_frame() {
let mut display = Display::<()>::new().unwrap();
let (server, mut client) = UnixStream::pair().unwrap();
display
.handle()
.insert_client(server, Arc::new(state::ClientState::default()))
.unwrap();
// wl_display.sync(new_id=2), using native-endian Wayland wire format.
for word in [1u32, 12u32 << 16, 2u32] {
client.write_all(&word.to_ne_bytes()).unwrap();
}
display.dispatch_clients(&mut ()).unwrap();
display.handle().flush_clients().unwrap();
client
.set_read_timeout(Some(Duration::from_secs(1)))
.unwrap();
let mut reply = [0u8; 12];
client.read_exact(&mut reply).unwrap();
assert_eq!(u32::from_ne_bytes(reply[0..4].try_into().unwrap()), 2);
assert_eq!(
u32::from_ne_bytes(reply[4..8].try_into().unwrap()) & 0xffff,
0
);
}
}
+3
View File
@@ -46,6 +46,9 @@ pub(crate) fn prepare_command(
let mut clients = vec![primary];
command.env_remove("WAYLAND_DISPLAY");
// Shell-launched applications must use the public socket, not inherit the
// private shell/effects connection or accidentally connect to the host.
command.env("FERESE_PUBLIC_WAYLAND_DISPLAY", &state.socket_name);
command.env_remove("FERESE_SHELL_CONTROL_SOCKET");
command.env("WAYLAND_SOCKET", clients[0].as_raw_fd().to_string());
+4
View File
@@ -147,6 +147,9 @@ pub struct Ferese {
viewport_spring_config: SpringConfig,
pub(crate) output_profiles: Vec<OutputProfile>,
pub cursor_status: CursorImageStatus,
// Cursor callbacks may run with Smithay's pointer mutex held. Rendering
// reads the pointer position, so defer it until event dispatch returns.
pub(crate) cursor_redraw_pending: bool,
pub(crate) cursor_theme: xcursor::CursorTheme,
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
pub intercepted_keys: HashSet<smithay::input::keyboard::Keycode>,
@@ -476,6 +479,7 @@ impl Ferese {
viewport_spring_config: config.viewport_spring_config,
output_profiles: config.output_profiles,
cursor_status: CursorImageStatus::default_named(),
cursor_redraw_pending: false,
cursor_theme,
named_cursors,
intercepted_keys: HashSet::new(),
+516 -100
View File
@@ -1,6 +1,10 @@
use std::{
collections::HashMap,
error::Error,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
@@ -11,8 +15,7 @@ use smithay::{
Color32F, ErasedContextId, Frame, ImportDma, Offscreen, Renderer, Texture,
damage::OutputDamageTracker,
element::{
AsRenderElements, Element, Id, Kind as RenderElementKind, RenderElement,
UnderlyingStorage,
Element, Id, Kind as RenderElementKind, RenderElement, UnderlyingStorage,
memory::MemoryRenderBufferRenderElement,
render_elements,
solid::{SolidColorBuffer, SolidColorRenderElement},
@@ -40,9 +43,9 @@ use smithay::{
},
input::pointer::{CursorImageStatus, CursorImageSurfaceData},
output::{Mode, Output, PhysicalProperties, Scale, Subpixel},
reexports::calloop::EventLoop,
reexports::wayland_protocols::wp::presentation_time::server::wp_presentation_feedback::Kind as PresentationKind,
reexports::wayland_server::Resource,
reexports::calloop::EventLoop,
utils::{
Buffer, Clock, Logical, Monotonic, Physical, Point, Rectangle, Scale as RenderScale, Size,
Transform,
@@ -94,7 +97,7 @@ const ROUNDED_TEXTURE_SHADER: &str = r#"#version 100
#extension GL_OES_EGL_image_external : require
#endif
precision mediump float;
precision highp float;
#if defined(EXTERNAL)
uniform samplerExternalOES tex;
#else
@@ -218,23 +221,63 @@ void main() {
}
"#;
// The material program paints either the fallback tint, the edges above client
// content, or the shadow outside the surface. All coordinates are physical px.
const MATERIAL_SHADER: &str = r#"
precision mediump float;
precision highp float;
uniform float alpha;
uniform vec4 tint;
uniform float noise_amount;
uniform vec4 visible_rect;
uniform float material_radius;
uniform float pixel_scale;
uniform vec2 local_origin;
uniform vec4 local_axes;
uniform vec2 logical_size;
uniform float paint_mode;
uniform float glass_edges;
uniform float edge_bar;
uniform vec4 shadow_rect;
uniform vec2 shadow_values;
varying vec2 v_coords;
float random(vec2 point) {
return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);
float rounded_distance(vec2 point, vec4 rect) {
vec2 half_size = rect.zw * 0.5;
float radius = min(material_radius, min(half_size.x, half_size.y));
vec2 d = abs(point - rect.xy - half_size) - (half_size - vec2(radius));
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius;
}
void main() {
float grain = (random(gl_FragCoord.xy) - 0.5) * noise_amount;
vec3 color = clamp(tint.rgb + vec3(grain), 0.0, 1.0);
float opacity = tint.a * alpha;
gl_FragColor = vec4(color * opacity, opacity);
float sdf = rounded_distance(gl_FragCoord.xy, visible_rect);
float coverage = 1.0 - smoothstep(-0.5, 0.5, sdf);
if (paint_mode > 1.5) {
float distance = max(rounded_distance(gl_FragCoord.xy, shadow_rect), 0.0);
float sigma = max(shadow_values.x * 0.5, 0.5);
float opacity = exp(-0.5 * distance * distance / (sigma * sigma))
* shadow_values.y * (1.0 - coverage) * alpha;
gl_FragColor = vec4(0.0, 0.0, 0.0, opacity);
} else if (paint_mode > 0.5) {
if (edge_bar > 0.5) {
float bottom = logical_size.y * pixel_scale
- dot(gl_FragCoord.xy - local_origin, local_axes.zw);
float opacity = 0.10 * (1.0 - smoothstep(pixel_scale - 0.5, pixel_scale + 0.5, bottom)) * coverage * alpha;
gl_FragColor = vec4(vec3(opacity), opacity);
return;
}
float border = smoothstep(-pixel_scale - 0.5, -pixel_scale + 0.5, sdf);
float hairline = smoothstep(-2.0 * pixel_scale - 0.5, -2.0 * pixel_scale + 0.5, sdf) * (1.0 - border);
// Inset only on the logical top, including rotated/flipped outputs.
float top = dot(gl_FragCoord.xy - local_origin, local_axes.zw);
float top_limit = min(2.0 * pixel_scale, logical_size.y * pixel_scale);
float highlight = (1.0 - smoothstep(top_limit - 0.5, top_limit + 0.5, top)) * (1.0 - border);
float opacity = 1.0 - (1.0 - 0.14 * border)
* (1.0 - glass_edges * 0.04 * hairline)
* (1.0 - glass_edges * 0.18 * highlight);
opacity *= coverage * alpha;
gl_FragColor = vec4(vec3(opacity), opacity);
} else {
float opacity = tint.a * coverage * alpha;
gl_FragColor = vec4(tint.rgb * opacity, opacity);
}
}
"#;
@@ -256,25 +299,35 @@ uniform float alpha;
uniform vec4 visible_rect;
uniform vec2 texture_size;
uniform float blur_radius;
uniform float tap_scale;
uniform float material_radius;
uniform float saturation;
uniform float brightness;
uniform vec4 tint;
uniform float noise_amount;
uniform float pixel_scale;
uniform vec2 local_origin;
uniform vec4 local_axes;
uniform vec2 logical_size;
varying vec2 v_coords;
// Static, surface-local two-octave fractal noise. A small polynomial hash
// avoids the precision-dependent sine hash and does not animate with time.
float random(vec2 point) {
return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);
vec3 p = fract(vec3(point.xyx) * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(random(i), random(i + vec2(1.0, 0.0)), f.x),
mix(random(i + vec2(0.0, 1.0)), random(i + vec2(1.0)), f.x), f.y);
}
float kernel_weight(int tap) {
int distance = tap < 0 ? -tap : tap;
if (distance == 0) {
return 6.0;
}
if (distance == 1) {
return 4.0;
}
return 1.0;
float kernel_weight(float distance) {
return exp(-distance * distance / 18.0);
}
void main() {
@@ -286,22 +339,41 @@ void main() {
return;
}
vec2 step_size = vec2(blur_radius * 0.5) / max(texture_size, vec2(1.0));
vec2 step_size = vec2(blur_radius / 6.0) / max(texture_size, vec2(1.0));
vec4 color = vec4(0.0);
for (int y = -2; y <= 2; y++) {
for (int x = -2; x <= 2; x++) {
float weight = kernel_weight(x) * kernel_weight(y);
vec2 offset = vec2(float(x), float(y)) * step_size;
float weight_sum = 0.0;
for (int y = -6; y <= 6; y++) {
if (abs(float(y)) * tap_scale > 6.0) { continue; }
for (int x = -6; x <= 6; x++) {
if (abs(float(x)) * tap_scale > 6.0) { continue; }
float weight = kernel_weight(float(x) * tap_scale) * kernel_weight(float(y) * tap_scale);
vec2 offset = vec2(float(x), float(y)) * step_size * tap_scale;
color += texture2D(tex, v_coords + offset) * weight;
weight_sum += weight;
}
}
color /= 256.0;
color /= weight_sum;
float luma = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
vec3 adjusted = mix(vec3(luma), color.rgb, saturation) * brightness;
adjusted = mix(adjusted, tint.rgb, tint.a);
adjusted += vec3((random(point) - 0.5) * noise_amount);
gl_FragColor = vec4(clamp(adjusted, 0.0, 1.0) * alpha, alpha);
vec2 relative = point - local_origin;
vec2 local = vec2(dot(relative, local_axes.xy), dot(relative, local_axes.zw)) / pixel_scale;
vec2 size = logical_size;
// CSS 155deg: direction (sin(155deg), -cos(155deg)). Project onto
// the full gradient line, including the aspect ratio of the surface.
vec2 direction = vec2(0.422618, 0.906308);
float position = dot(local, direction) / max(dot(size, direction), 1.0);
adjusted = mix(adjusted, vec3(1.0), 0.10 * clamp(1.0 - position / 0.40, 0.0, 1.0));
float grain = (noise(local * 0.8) + 0.5 * noise(local * 1.6)) / 1.5;
vec3 overlay = mix(2.0 * adjusted * grain,
1.0 - 2.0 * (1.0 - adjusted) * (1.0 - grain), step(vec3(0.5), adjusted));
adjusted = mix(adjusted, overlay, noise_amount);
vec2 half_size = visible_rect.zw * 0.5;
vec2 distance = abs(point - visible_rect.xy - half_size) - (half_size - vec2(material_radius));
float sdf = length(max(distance, 0.0)) + min(max(distance.x, distance.y), 0.0) - material_radius;
float coverage = alpha * (1.0 - smoothstep(-0.5, 0.5, sdf));
gl_FragColor = vec4(clamp(adjusted, 0.0, 1.0) * coverage, coverage);
}
"#;
@@ -333,6 +405,16 @@ pub(crate) struct BackdropProgram(GlesTexProgram);
#[derive(Clone, Debug, PartialEq)]
struct MaterialParameters {
radius: f32,
pixel_scale: f32,
local_origin: [f32; 2],
local_axes: [f32; 4],
logical_size: [f32; 2],
glass_edges: f32,
edge_bar: f32,
shadow_rect: [f32; 4],
shadow_values: [f32; 2],
shadow_bounds: Rectangle<i32, Logical>,
geometry: Rectangle<i32, Logical>,
tint: [f32; 4],
noise: f32,
@@ -340,6 +422,7 @@ struct MaterialParameters {
scene_generation: u64,
opaque: bool,
blur: f32,
tap_scale: f32,
saturation: f32,
brightness: f32,
sample_geometry: Rectangle<i32, Logical>,
@@ -352,6 +435,8 @@ struct MaterialParameters {
struct CachedMaterial {
element: CachedMaterialElement,
parameters: MaterialParameters,
edge: PixelShaderElement,
shadow: PixelShaderElement,
}
#[derive(Clone, Debug)]
@@ -367,6 +452,7 @@ pub(crate) struct MaterialBuffers {
#[derive(Clone, Debug)]
struct BackdropRenderElement {
capture_dirty: Arc<AtomicBool>,
texture: GlesTexture,
program: GlesTexProgram,
id: Id,
@@ -379,6 +465,7 @@ struct BackdropRenderElement {
impl BackdropRenderElement {
fn new(texture: GlesTexture, program: GlesTexProgram, parameters: &MaterialParameters) -> Self {
Self {
capture_dirty: Arc::new(AtomicBool::new(true)),
texture,
program,
id: Id::new(),
@@ -390,6 +477,7 @@ impl BackdropRenderElement {
}
fn update(&mut self, parameters: &MaterialParameters) {
self.capture_dirty.store(true, Ordering::Relaxed);
self.geometry = parameters.sample_geometry;
self.capture_rect = framebuffer_capture_rect(parameters.sample_framebuffer);
self.uniforms = backdrop_uniforms(parameters);
@@ -419,11 +507,7 @@ impl Element for BackdropRenderElement {
scale: RenderScale<f64>,
commit: Option<CommitCounter>,
) -> DamageSet<i32, Physical> {
if commit == Some(self.commit) {
DamageSet::default()
} else {
DamageSet::from_slice(&[Rectangle::from_size(self.geometry(scale).size)])
}
backdrop_damage(self.geometry(scale).size, self.commit, commit)
}
fn opaque_regions(&self, _scale: RenderScale<f64>) -> OpaqueRegions<i32, Physical> {
@@ -441,6 +525,19 @@ impl Element for BackdropRenderElement {
}
}
fn backdrop_damage(
size: Size<i32, Physical>,
current: CommitCounter,
previous: Option<CommitCounter>,
) -> DamageSet<i32, Physical> {
if previous == Some(current) {
DamageSet::default()
} else {
// A new scene needs its entire sampling halo repainted before capture.
DamageSet::from_slice(&[Rectangle::from_size(size)])
}
}
impl RenderElement<GlesRenderer> for BackdropRenderElement {
fn draw(
&self,
@@ -452,30 +549,36 @@ impl RenderElement<GlesRenderer> for BackdropRenderElement {
) -> Result<(), GlesError> {
let texture = self.texture.tex_id();
let [x, y, width, height] = self.capture_rect;
frame.with_context(|gl| unsafe {
gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, texture);
gl.TexParameteri(
smithay::backend::renderer::gles::ffi::TEXTURE_2D,
smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_S,
smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32,
);
gl.TexParameteri(
smithay::backend::renderer::gles::ffi::TEXTURE_2D,
smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_T,
smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32,
);
gl.CopyTexSubImage2D(
smithay::backend::renderer::gles::ffi::TEXTURE_2D,
0,
0,
0,
x,
y,
width,
height,
);
gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, 0);
})?;
// Buffer-age restoration and unrelated partial damage must reuse the
// clean captured scene. Recapturing a partly repainted framebuffer
// would feed previous glass pixels back into the blur.
if self.capture_dirty.load(Ordering::Relaxed) {
frame.with_context(|gl| unsafe {
gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, texture);
gl.TexParameteri(
smithay::backend::renderer::gles::ffi::TEXTURE_2D,
smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_S,
smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32,
);
gl.TexParameteri(
smithay::backend::renderer::gles::ffi::TEXTURE_2D,
smithay::backend::renderer::gles::ffi::TEXTURE_WRAP_T,
smithay::backend::renderer::gles::ffi::CLAMP_TO_EDGE as i32,
);
gl.CopyTexSubImage2D(
smithay::backend::renderer::gles::ffi::TEXTURE_2D,
0,
0,
0,
x,
y,
width,
height,
);
gl.BindTexture(smithay::backend::renderer::gles::ffi::TEXTURE_2D, 0);
})?;
self.capture_dirty.store(false, Ordering::Relaxed);
}
frame.render_texture_from_to(
&self.texture,
@@ -1452,45 +1555,149 @@ fn layer_elements(
let mut elements = Vec::new();
for (geometry, layer) in layers {
elements.extend(
AsRenderElements::<GlesRenderer>::render_elements::<
WaylandSurfaceRenderElement<GlesRenderer>,
>(
&layer,
for (popup, offset) in PopupManager::popups_for_surface(layer.wl_surface()) {
let mut bounds = popup.geometry();
let content_origin = geometry.loc + offset - bounds.loc;
bounds.loc = geometry.loc + offset;
append_material_surface(
state,
renderer,
geometry.loc.to_physical_precise_round(scale),
scale.into(),
1.0,
)
.into_iter()
.filter_map(|element| {
let origin = Point::<i32, Physical>::default();
let element = RescaleRenderElement::from_element(element, origin, 1.0);
let element =
RelocateRenderElement::from_element(element, origin, Relocate::Relative);
CropRenderElement::from_element(element, scale, output_crop).map(Into::into)
}),
);
if let Some(material) = material_element(state, renderer, output, &layer, geometry) {
elements.push(material);
output,
popup.wl_surface(),
bounds,
content_origin,
scale,
output_crop,
&mut elements,
);
}
append_material_surface(
state,
renderer,
output,
layer.wl_surface(),
geometry,
geometry.loc,
scale,
output_crop,
&mut elements,
);
}
elements
}
#[allow(clippy::too_many_arguments)]
fn append_material_surface(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
geometry: Rectangle<i32, Logical>,
content_origin: Point<i32, Logical>,
scale: f64,
output_crop: Rectangle<i32, Physical>,
elements: &mut Vec<AnimatedWindowRenderElement>,
) {
let radius = if crate::effects::surface_role(surface)
.is_some_and(|(role, _)| role == crate::effects::SemanticRole::Panel)
{
state.theme_settings.panel_radius
} else {
state.theme_settings.material_radius
};
let material = material_element(state, renderer, output, surface, geometry, radius as f32);
let clip = material.as_ref().and_then(|_| {
let mode = output.current_mode()?;
let program = rounded_clip_program(state, renderer)?;
Some((
program.texture,
framebuffer_clip_rect(
geometry.to_physical_precise_round(scale),
mode.size,
output.current_transform().invert(),
),
scaled_effect_value(radius, geometry, scale),
))
});
// Smithay element lists are front to back: edges, content, material, shadow.
let material = material.map(|(edge, background, shadow)| {
elements.push(edge);
(background, shadow)
});
let content = render_elements_from_surface_tree::<
GlesRenderer,
WaylandSurfaceRenderElement<GlesRenderer>,
>(
renderer,
surface,
content_origin.to_physical_precise_round(scale),
scale,
1.0,
RenderElementKind::Unspecified,
);
elements.extend(content.into_iter().filter_map(|element| {
let origin = Point::<i32, Physical>::default();
let element = if let Some((program, clip_rect, radius)) = &clip {
WindowContentRenderElement::Rounded(RoundedSurfaceRenderElement {
inner: element,
program: program.clone(),
clip_rect: *clip_rect,
radius: *radius,
clip_changed: false,
})
} else {
WindowContentRenderElement::Popup(element)
};
let element = RescaleRenderElement::from_element(element, origin, 1.0);
let element = RelocateRenderElement::from_element(element, origin, Relocate::Relative);
CropRenderElement::from_element(element, scale, output_crop).map(Into::into)
}));
if let Some((background, shadow)) = material {
elements.push(background);
elements.push(shadow);
}
}
fn material_element(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
layer: &LayerSurface,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
geometry: Rectangle<i32, Logical>,
) -> Option<AnimatedWindowRenderElement> {
let surface = layer.wl_surface();
radius: f32,
) -> Option<(
AnimatedWindowRenderElement,
AnimatedWindowRenderElement,
AnimatedWindowRenderElement,
)> {
material_element_for_style(
state,
renderer,
output,
surface,
geometry,
radius,
state.theme_settings.material_style,
)
}
#[allow(clippy::too_many_arguments)]
fn material_element_for_style(
state: &mut Ferese,
renderer: &mut GlesRenderer,
output: &Output,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
geometry: Rectangle<i32, Logical>,
radius: f32,
style: crate::config::MaterialStyle,
) -> Option<(
AnimatedWindowRenderElement,
AnimatedWindowRenderElement,
AnimatedWindowRenderElement,
)> {
let (role, generation) = crate::effects::surface_role(surface)?;
let material = crate::effects::resolve_material(role, state.theme_settings.material_style);
let material = crate::effects::resolve_material(role, style, state.theme_settings.glass);
let mode = output.current_mode()?;
let scale = output.current_scale().fractional_scale();
let output_logical = smithay::utils::Size::<i32, Logical>::from((
@@ -1501,9 +1708,46 @@ fn material_element(
expanded_blur_region(geometry, material.blur.ceil() as i32, output_logical);
let sample_physical = sample_geometry.to_physical_precise_round(scale);
let transform = output.current_transform().invert();
let [red, green, blue, _] = state.theme_settings.surface_base_color.0;
let [red, green, blue, _] =
if state.theme_settings.material_style == crate::config::MaterialStyle::Glass {
state.theme_settings.glass.tint.0
} else {
state.theme_settings.surface_base_color.0
};
let radius = radius.min(geometry.size.w.min(geometry.size.h) as f32 * 0.5);
let [offset_y, shadow_blur, shadow_opacity] = material.shadow;
let edge_bar =
role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0;
let shadow_opacity = if edge_bar { 0.0 } else { shadow_opacity };
let shadow_geometry = Rectangle::new(
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
geometry.size,
);
let tint = [red, green, blue, material.opacity];
let (local_origin, local_axes) = material_local_coordinates(
geometry.loc.to_physical_precise_round(scale),
mode.size,
transform,
);
let parameters = MaterialParameters {
radius: radius * scale as f32,
pixel_scale: scale as f32,
local_origin,
local_axes,
logical_size: [geometry.size.w as f32, geometry.size.h as f32],
edge_bar: if edge_bar { 1.0 } else { 0.0 },
glass_edges: if material.style == crate::config::MaterialStyle::Glass {
1.0
} else {
0.0
},
shadow_rect: framebuffer_clip_rect(
shadow_geometry.to_physical_precise_round(scale),
mode.size,
transform,
),
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
geometry,
tint,
noise: material.noise,
@@ -1511,8 +1755,14 @@ fn material_element(
scene_generation: matches!(material.style, crate::config::MaterialStyle::Glass)
.then(|| state.material_scene_generation())
.unwrap_or(0),
opaque: material.opacity == 1.0,
opaque: material.opacity == 1.0 && radius == 0.0,
blur: material.blur * scale as f32,
tap_scale: if state.theme_settings.glass.quality >= crate::config::GlassQuality::ReducedBlur
{
2.0
} else {
1.0
},
saturation: material.saturation,
brightness: material.brightness,
sample_geometry,
@@ -1574,6 +1824,21 @@ fn material_element(
}
})
.flatten();
if needs_replacement
&& replacement.is_none()
&& material.style == crate::config::MaterialStyle::Glass
{
return material_element_for_style(
state,
renderer,
output,
surface,
geometry,
radius,
crate::config::MaterialStyle::Translucent,
);
}
let decoration_program = material_program(state, renderer)?;
let buffers = state.material_buffers.entry(surface.clone()).or_default();
if !buffers.contexts.contains_key(&context) {
buffers.contexts.insert(
@@ -1581,12 +1846,39 @@ fn material_element(
CachedMaterial {
element: replacement.take()?,
parameters: parameters.clone(),
edge: PixelShaderElement::new(
decoration_program.0.clone(),
geometry,
None,
1.0,
decoration_uniforms(&parameters, 1.0),
RenderElementKind::Unspecified,
),
shadow: PixelShaderElement::new(
decoration_program.0.clone(),
parameters.shadow_bounds,
None,
1.0,
decoration_uniforms(&parameters, 2.0),
RenderElementKind::Unspecified,
),
},
);
}
let cached = buffers.contexts.get_mut(&context)?;
if let Some(replacement) = replacement.take() {
cached.element = replacement;
}
if cached.parameters != parameters {
cached.edge.resize(geometry, None);
cached
.edge
.update_uniforms(decoration_uniforms(&parameters, 1.0));
cached.shadow.resize(parameters.shadow_bounds, None);
cached
.shadow
.update_uniforms(decoration_uniforms(&parameters, 2.0));
match &mut cached.element {
CachedMaterialElement::Tint(element) => {
if cached.parameters.geometry != parameters.geometry
@@ -1599,20 +1891,22 @@ fn material_element(
}
element.update_uniforms(material_uniforms(&parameters));
}
CachedMaterialElement::Backdrop(element)
if cached.parameters.sample_physical.size == parameters.sample_physical.size =>
{
CachedMaterialElement::Backdrop(element) => {
element.update(&parameters);
}
_ => cached.element = replacement.take()?,
}
cached.parameters = parameters;
}
Some(match &cached.element {
let background = match &cached.element {
CachedMaterialElement::Tint(element) => element.clone().into(),
CachedMaterialElement::Backdrop(element) => element.clone().into(),
})
};
Some((
cached.edge.clone().into(),
background,
cached.shadow.clone().into(),
))
}
fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<MaterialProgram> {
@@ -1622,8 +1916,18 @@ fn material_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<M
}
let uniforms = [
UniformName::new("visible_rect", UniformType::_4f),
UniformName::new("material_radius", UniformType::_1f),
UniformName::new("tint", UniformType::_4f),
UniformName::new("noise_amount", UniformType::_1f),
UniformName::new("pixel_scale", UniformType::_1f),
UniformName::new("local_origin", UniformType::_2f),
UniformName::new("local_axes", UniformType::_4f),
UniformName::new("logical_size", UniformType::_2f),
UniformName::new("paint_mode", UniformType::_1f),
UniformName::new("glass_edges", UniformType::_1f),
UniformName::new("edge_bar", UniformType::_1f),
UniformName::new("shadow_rect", UniformType::_4f),
UniformName::new("shadow_values", UniformType::_2f),
];
match renderer.compile_custom_pixel_shader(MATERIAL_SHADER, &uniforms) {
Ok(program) => {
@@ -1646,12 +1950,18 @@ fn backdrop_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<B
let uniforms = [
UniformName::new("visible_rect", UniformType::_4f),
UniformName::new("material_radius", UniformType::_1f),
UniformName::new("texture_size", UniformType::_2f),
UniformName::new("blur_radius", UniformType::_1f),
UniformName::new("tap_scale", UniformType::_1f),
UniformName::new("saturation", UniformType::_1f),
UniformName::new("brightness", UniformType::_1f),
UniformName::new("tint", UniformType::_4f),
UniformName::new("noise_amount", UniformType::_1f),
UniformName::new("pixel_scale", UniformType::_1f),
UniformName::new("local_origin", UniformType::_2f),
UniformName::new("local_axes", UniformType::_4f),
UniformName::new("logical_size", UniformType::_2f),
];
match renderer.compile_custom_texture_shader(BACKDROP_SHADER, &uniforms) {
Ok(program) => {
@@ -1667,14 +1977,33 @@ fn backdrop_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<B
}
fn material_uniforms(parameters: &MaterialParameters) -> Vec<Uniform<'static>> {
decoration_uniforms(parameters, 0.0)
}
fn decoration_uniforms(parameters: &MaterialParameters, paint_mode: f32) -> Vec<Uniform<'static>> {
vec![
Uniform::new("pixel_scale", parameters.pixel_scale).into_owned(),
Uniform::new("local_origin", parameters.local_origin).into_owned(),
Uniform::new("local_axes", parameters.local_axes).into_owned(),
Uniform::new("logical_size", parameters.logical_size).into_owned(),
Uniform::new("paint_mode", paint_mode).into_owned(),
Uniform::new("glass_edges", parameters.glass_edges).into_owned(),
Uniform::new("edge_bar", parameters.edge_bar).into_owned(),
Uniform::new("shadow_rect", parameters.shadow_rect).into_owned(),
Uniform::new("shadow_values", parameters.shadow_values).into_owned(),
Uniform::new("visible_rect", parameters.visible_framebuffer).into_owned(),
Uniform::new("material_radius", parameters.radius).into_owned(),
Uniform::new("tint", parameters.tint).into_owned(),
Uniform::new("noise_amount", parameters.noise).into_owned(),
]
}
fn backdrop_uniforms(parameters: &MaterialParameters) -> Vec<Uniform<'static>> {
vec![
Uniform::new("pixel_scale", parameters.pixel_scale).into_owned(),
Uniform::new("local_origin", parameters.local_origin).into_owned(),
Uniform::new("local_axes", parameters.local_axes).into_owned(),
Uniform::new("logical_size", parameters.logical_size).into_owned(),
Uniform::new("material_radius", parameters.radius).into_owned(),
Uniform::new("visible_rect", parameters.visible_framebuffer).into_owned(),
Uniform::new(
"texture_size",
@@ -1685,6 +2014,7 @@ fn backdrop_uniforms(parameters: &MaterialParameters) -> Vec<Uniform<'static>> {
)
.into_owned(),
Uniform::new("blur_radius", parameters.blur).into_owned(),
Uniform::new("tap_scale", parameters.tap_scale).into_owned(),
Uniform::new("saturation", parameters.saturation).into_owned(),
Uniform::new("brightness", parameters.brightness).into_owned(),
Uniform::new("tint", parameters.tint).into_owned(),
@@ -1692,6 +2022,21 @@ fn backdrop_uniforms(parameters: &MaterialParameters) -> Vec<Uniform<'static>> {
]
}
fn material_local_coordinates(
origin: Point<i32, Physical>,
output_size: Size<i32, Physical>,
transform: Transform,
) -> ([f32; 2], [f32; 4]) {
let area = transform.transform_size(output_size);
let mapped = transform.transform_point_in(origin, &area);
let x = transform.transform_point_in(origin + Point::from((1, 0)), &area) - mapped;
let y = transform.transform_point_in(origin + Point::from((0, 1)), &area) - mapped;
(
[mapped.x as f32, mapped.y as f32],
[x.x as f32, x.y as f32, y.x as f32, y.y as f32],
)
}
fn framebuffer_capture_rect(rect: [f32; 4]) -> [i32; 4] {
[
rect[0].round() as i32,
@@ -1809,18 +2154,20 @@ mod tests {
element::{Element, Id},
utils::CommitCounter,
},
utils::{Buffer, Logical, Physical, Rectangle, Scale, Transform},
utils::{Buffer, Logical, Physical, Point, Rectangle, Scale, Size, Transform},
};
use super::{
color_with_alpha, expanded_blur_region, framebuffer_capture_rect, framebuffer_clip_rect,
normalized_scale, rounded_visual_rect, scaled_visual_rect, shadow_bounds,
material_local_coordinates, normalized_scale, rounded_visual_rect, scaled_visual_rect,
shadow_bounds,
};
#[derive(Debug)]
struct DamageElement {
id: Id,
geometry: Rectangle<i32, Logical>,
commit: CommitCounter,
}
impl DamageElement {
@@ -1828,6 +2175,7 @@ mod tests {
Self {
id: Id::new(),
geometry,
commit: CommitCounter::default(),
}
}
}
@@ -1838,7 +2186,7 @@ mod tests {
}
fn current_commit(&self) -> CommitCounter {
CommitCounter::default()
self.commit
}
fn src(&self) -> Rectangle<f64, Buffer> {
@@ -1853,6 +2201,38 @@ mod tests {
fn geometry(&self, scale: Scale<f64>) -> Rectangle<i32, Physical> {
self.geometry.to_physical_precise_round(scale)
}
fn damage_since(
&self,
scale: Scale<f64>,
commit: Option<CommitCounter>,
) -> smithay::backend::renderer::utils::DamageSet<i32, Physical> {
super::backdrop_damage(self.geometry(scale).size, self.current_commit(), commit)
}
}
#[test]
fn backdrop_is_idle_until_scene_changes_then_repaints_full_sampling_region() {
for scale in [1.0, 1.25, 1.8, 2.0] {
for transform in [Transform::Normal, Transform::Flipped180] {
let mut tracker = OutputDamageTracker::new((1600, 1200), scale, transform);
let mut backdrop =
DamageElement::new(Rectangle::new((50, 40).into(), (400, 240).into()));
tracker.damage_output(0, &[&backdrop]).unwrap();
for _ in 0..3 {
assert!(tracker.damage_output(1, &[&backdrop]).unwrap().0.is_none());
backdrop.commit.increment();
let damage = tracker
.damage_output(1, &[&backdrop])
.unwrap()
.0
.cloned()
.unwrap();
let bounds = backdrop.geometry(scale.into());
assert!(damage.iter().any(|rect| rect.contains_rect(bounds)));
}
}
}
}
#[test]
@@ -1877,6 +2257,42 @@ mod tests {
);
}
#[test]
fn glass_lighting_coordinates_follow_every_output_transform() {
let origin = Point::<i32, Physical>::from((120, 90));
let size = Size::<i32, Physical>::from((1000, 800));
for transform in [
Transform::Normal,
Transform::_90,
Transform::_180,
Transform::_270,
Transform::Flipped,
Transform::Flipped90,
Transform::Flipped180,
Transform::Flipped270,
] {
let (mapped_origin, axes) = material_local_coordinates(origin, size, transform);
for local in [(0, 0), (24, 0), (0, 35), (57, 81)] {
let point = transform.transform_point_in(
origin + Point::from(local),
&transform.transform_size(size),
);
let relative = [
point.x as f32 - mapped_origin[0],
point.y as f32 - mapped_origin[1],
];
assert_eq!(
relative[0] * axes[0] + relative[1] * axes[1],
local.0 as f32
);
assert_eq!(
relative[0] * axes[2] + relative[1] * axes[3],
local.1 as f32
);
}
}
}
#[test]
fn clip_rect_follows_output_transform() {
let geometry = Rectangle::<i32, Physical>::new((10, 5).into(), (30, 40).into());
+7
View File
@@ -10,5 +10,12 @@ focus_follows_mouse = false
[layout]
smart_gaps = false
# Edge-to-edge menu bar; its foreground palette is independent of popup surfaces.
[theme.surface.bar]
background = "#1C202E"
opacity = 0.95
text_primary = "#F0F3FA"
text_muted = "#AAB4C7"
# Super+Enter opens Foot. Super+Shift+E logs out.
# See README.md for monitor profiles and additional bindings.
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
<rect x="1.4" y="5.5" width="15.4" height="9" rx="2.4" stroke="currentColor" stroke-width="1.8"/>
<path d="M18 8.1c.6.2 1 .8 1 1.5v.8c0 .7-.4 1.3-1 1.5V8.1Z" fill="currentColor"/>
<rect x="3.8" y="7.9" width="10.6" height="4.2" rx="1" fill="currentColor"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
<path d="M22 10v4"/>
<rect x="5" y="9" width="12" height="6" rx="0.9" fill="currentColor" stroke="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 355 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 20V6a2 2 0 0 1 2-2h10M7 11.5h9"/>
<path d="M4 20h3"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

+5 -5
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<rect x="1.5" y="2" width="7.2" height="6.2" rx="1.7"/>
<rect x="11.3" y="2" width="7.2" height="9.2" rx="1.7"/>
<rect x="1.5" y="10.8" width="7.2" height="7.2" rx="1.7"/>
<rect x="11.3" y="13.8" width="7.2" height="4.2" rx="1.7"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.8"/>
<rect x="14" y="3" width="7" height="7" rx="1.8"/>
<rect x="3" y="14" width="7" height="7" rx="1.8"/>
<rect x="14" y="14" width="7" height="7" rx="1.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 403 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3c-.9 0-1.5.7-1.5 1.7V9L3 13v2l7.5-2v4.5L8 19v1.5l4-1 4 1V19l-2.5-1.5V13l7.5 2v-2l-7.5-4V4.7C13.5 3.7 12.9 3 12 3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
<path d="M22 10v4"/>
<rect x="5" y="9" width="3" height="6" rx="0.9" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
<path d="M22 10v4"/>
<rect x="5" y="9" width="6" height="6" rx="0.9" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
<path d="M22 10v4"/>
<rect x="5" y="9" width="9" height="6" rx="0.9" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
<path d="M22 10v4"/>
<path d="M12.2 7.8 8.1 12.7h3l-1 3.5 4.5-5.2h-3Z" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
<path d="M22 10v4"/>
</svg>

After

Width:  |  Height:  |  Size: 268 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="6" width="18" height="12" rx="2.8"/>
<path d="M22 10v4"/>
<rect x="5" y="9" width="12" height="6" rx="0.9" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.5 7.5l10 9L12 21V3l5.5 4.5-10 9"/>
<circle cx="3.5" cy="12" r="1.1" fill="currentColor" stroke="none"/>
<circle cx="20.5" cy="12" r="1.1" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 383 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3l5.5 4.5-3.2 2.9M12 3v5M7.5 16.5l2.7-2.4M12 16v5l3.5-2.9"/>
<path d="M4 4l16 16"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.5 7.5l10 9L12 21V3l5.5 4.5-10 9"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.4 1.4m11.2 11.2L19 19M19 5l-1.4 1.4M6.4 17.6 5 19"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="m5 9 7 7 7-7"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="6" rx="3"/>
<rect x="3" y="14" width="18" height="6" rx="3"/>
<circle cx="7" cy="7" r="1.35" fill="currentColor" stroke="none"/>
<circle cx="17" cy="17" r="1.35" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 14.2A8.4 8.4 0 1 1 9.8 4a6.6 6.6 0 0 0 10.2 10.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 3h14v12h-4v3H9v-3H5ZM8 3v4m4-4v4m4-4v4M12 18v3M8 21h8"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="5" y="10" width="14" height="11" rx="2.8"/>
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14.5v2"/>
</svg>

After

Width:  |  Height:  |  Size: 294 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6a3 3 0 0 1 6 0v5M5.5 11.5a6.5 6.5 0 0 0 10.6 5M18.5 11.5a6.5 6.5 0 0 1-.4 2.3M12 18v3m-3 0h6"/>
<path d="M4 4l16 16"/>
</svg>

After

Width:  |  Height:  |  Size: 327 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="3" width="6" height="11" rx="3"/>
<path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3m-3 0h6"/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 14.2A8.4 8.4 0 1 1 9.8 4a6.6 6.6 0 0 0 10.2 10.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.3 4.8A5.5 5.5 0 0 1 17.5 10v3M6.7 8.6a5.5 5.5 0 0 0-.2 1.4v4.5L5 17h10M10 20a2.3 2.3 0 0 0 4 0"/>
<path d="M4 4l16 16"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 17h14l-1.5-2.5V10a5.5 5.5 0 0 0-11 0v4.5Z"/>
<path d="M10 20a2.3 2.3 0 0 0 4 0M12 3v1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 298 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.8v8M7.4 5.9a8 8 0 1 0 9.2 0"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9h4l5-4v14l-5-4H3Z"/>
<path d="M15.2 8.5a5 5 0 0 1 0 7"/>
<path d="M18.4 5.5a9.2 9.2 0 0 1 0 13"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9h4l5-4v14l-5-4H3Z"/>
<path d="M15.2 8.5a5 5 0 0 1 0 7" opacity="0.24"/>
<path d="M18.4 5.5a9.2 9.2 0 0 1 0 13" opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9h4l5-4v14l-5-4H3Z"/>
<path d="M15.2 8.5a5 5 0 0 1 0 7"/>
<path d="M18.4 5.5a9.2 9.2 0 0 1 0 13" opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 323 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9h4l5-4v14l-5-4H3Z"/>
<path d="M16 9l5 6m0-6-5 6"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 7.8a14.5 14.5 0 0 1 19 0"/>
<path d="M5.7 11.4a9.6 9.6 0 0 1 12.6 0"/>
<path d="M8.9 15a4.7 4.7 0 0 1 6.2 0"/>
<circle cx="12" cy="18.4" r="1.2" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 7.8a14.5 14.5 0 0 1 19 0" opacity="0.24"/>
<path d="M5.7 11.4a9.6 9.6 0 0 1 12.6 0" opacity="0.24"/>
<path d="M8.9 15a4.7 4.7 0 0 1 6.2 0"/>
<circle cx="12" cy="18.4" r="1.2" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 7.8a14.5 14.5 0 0 1 19 0" opacity="0.24"/>
<path d="M5.7 11.4a9.6 9.6 0 0 1 12.6 0"/>
<path d="M8.9 15a4.7 4.7 0 0 1 6.2 0"/>
<circle cx="12" cy="18.4" r="1.2" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 409 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 7.8a14.5 14.5 0 0 1 2.2-1.5M9 4.8a14.5 14.5 0 0 1 12.5 3M5.7 11.4a9.6 9.6 0 0 1 2.1-1.3M13.5 9.1a9.6 9.6 0 0 1 4.8 2.3M8.9 15a4.7 4.7 0 0 1 6.2 0"/>
<circle cx="12" cy="18.4" r="1.2" fill="currentColor" stroke="none"/>
<path d="M4 4l16 16"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M2 8.1v3.8c0 .8.6 1.4 1.4 1.4h2.1l4 3.3c.6.5 1.5.1 1.5-.7V4.1c0-.8-.9-1.2-1.5-.7l-4 3.3H3.4C2.6 6.7 2 7.3 2 8.1Z" fill="currentColor" stroke="none"/>
<path d="M13.4 7.2a4.1 4.1 0 0 1 0 5.6"/>
<path d="M16 4.8a7.4 7.4 0 0 1 0 10.4"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9h4l5-4v14l-5-4H3Z"/>
<path d="M15.2 8.5a5 5 0 0 1 0 7"/>
<path d="M18.4 5.5a9.2 9.2 0 0 1 0 13"/>
</svg>

Before

Width:  |  Height:  |  Size: 390 B

After

Width:  |  Height:  |  Size: 308 B

+5 -5
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.15" stroke-linecap="round">
<path d="M2.1 7.2a12.1 12.1 0 0 1 15.8 0"/>
<path d="M5.2 10.5a7.4 7.4 0 0 1 9.6 0"/>
<path d="M8.3 13.7a2.7 2.7 0 0 1 3.4 0"/>
<circle cx="10" cy="16.6" r="1.25" fill="currentColor" stroke="none"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.5 7.8a14.5 14.5 0 0 1 19 0"/>
<path d="M5.7 11.4a9.6 9.6 0 0 1 12.6 0"/>
<path d="M8.9 15a4.7 4.7 0 0 1 6.2 0"/>
<circle cx="12" cy="18.4" r="1.2" fill="currentColor" stroke="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 352 B

After

Width:  |  Height:  |  Size: 394 B

+187 -12
View File
@@ -11,11 +11,34 @@ pub(crate) struct ShellConfig {
pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig,
pub(crate) theme: ShellTheme,
pub(crate) status: StatusConfig,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub(crate) struct StatusConfig {
pub(crate) battery_percentage: bool,
pub(crate) low_battery_threshold: u8,
pub(crate) settings_command: Option<Vec<String>>,
}
impl Default for StatusConfig {
fn default() -> Self {
Self {
battery_percentage: true,
low_battery_threshold: 20,
settings_command: None,
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct ShellTheme {
pub(crate) bar_background: [u8; 4],
pub(crate) bar_text_primary: [u8; 4],
pub(crate) bar_text_muted: [u8; 4],
pub(crate) surface_base: [u8; 4],
pub(crate) surface_popover: [u8; 4],
pub(crate) text_primary: [u8; 4],
pub(crate) text_muted: [u8; 4],
pub(crate) accent: [u8; 4],
@@ -23,8 +46,10 @@ pub(crate) struct ShellTheme {
pub(crate) shadow: [u8; 4],
pub(crate) bar_height: f32,
pub(crate) bar_margin_top: i32,
pub(crate) bar_window_gap: i32,
pub(crate) bar_margin_horizontal: i32,
pub(crate) bar_radius: f32,
pub(crate) material_radius: f32,
pub(crate) panel_padding: f32,
pub(crate) control_gap: f32,
pub(crate) shadow_offset_y: f32,
@@ -35,18 +60,24 @@ pub(crate) struct ShellTheme {
impl Default for ShellTheme {
fn default() -> Self {
Self {
bar_background: [28, 32, 46, 242],
bar_text_primary: [240, 243, 250, 255],
bar_text_muted: [170, 180, 199, 255],
surface_base: [17, 24, 33, 255],
surface_popover: [17, 24, 33, 245],
text_primary: [244, 247, 251, 255],
text_muted: [127, 138, 152, 255],
accent: [91, 140, 255, 255],
border: [255, 255, 255, 24],
shadow: [0, 0, 0, 85],
bar_height: 38.0,
bar_margin_top: 4,
bar_margin_horizontal: 10,
bar_radius: 19.0,
bar_height: 28.0,
bar_margin_top: 0,
bar_window_gap: 8,
bar_margin_horizontal: 0,
bar_radius: 0.0,
material_radius: 14.0,
panel_padding: 12.0,
control_gap: 8.0,
control_gap: 12.0,
shadow_offset_y: 4.0,
shadow_blur: 18.0,
shadow_opacity: 0.20,
@@ -54,6 +85,15 @@ impl Default for ShellTheme {
}
}
impl ShellTheme {
// The bar has its own foreground tokens, matched to its dark glass surface.
pub(crate) fn for_bar(mut self) -> Self {
self.text_primary = self.bar_text_primary;
self.text_muted = self.bar_text_muted;
self
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub(crate) struct WallpaperConfig {
pub(crate) path: Option<PathBuf>,
@@ -71,12 +111,23 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)]
struct FereseConfig {
#[serde(default)]
appearance: AppearanceConfig,
#[serde(default)]
theme: ThemeConfig,
#[serde(default)]
status: StatusConfig,
}
#[derive(Debug, Default, Deserialize)]
struct AppearanceConfig {
corner_radius: Option<f32>,
}
#[derive(Debug, Default, Deserialize)]
struct ThemeConfig {
#[serde(default)]
surface: SurfaceConfig,
#[serde(default)]
colors: ThemeColorsConfig,
#[serde(default)]
@@ -89,6 +140,50 @@ struct ThemeConfig {
background: WallpaperConfig,
}
#[derive(Debug, Default, Deserialize)]
struct SurfaceConfig {
#[serde(default)]
bar: BarConfig,
#[serde(default)]
popover: PopoverConfig,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
struct BarConfig {
background: String,
// If omitted, preserve the alpha supplied in `background`.
opacity: Option<f32>,
text_primary: String,
text_muted: String,
}
impl Default for BarConfig {
fn default() -> Self {
Self {
background: "#1C202EF2".to_owned(),
opacity: None,
text_primary: "#F0F3FA".to_owned(),
text_muted: "#AAB4C7".to_owned(),
}
}
}
#[derive(Debug, Deserialize)]
struct PopoverConfig {
#[serde(default = "default_popover_opacity")]
opacity: f32,
}
impl Default for PopoverConfig {
fn default() -> Self {
Self {
opacity: default_popover_opacity(),
}
}
}
const fn default_popover_opacity() -> f32 {
0.96
}
#[derive(Debug, Default, Deserialize)]
struct TypographyConfig {
font_family: Option<String>,
@@ -129,6 +224,8 @@ struct ThemeGeometryConfig {
top_bar_height: f32,
#[serde(default = "default_bar_margin_top")]
top_bar_margin_top: i32,
#[serde(default = "default_bar_window_gap")]
top_bar_window_gap: i32,
#[serde(default = "default_bar_margin_horizontal")]
top_bar_margin_horizontal: i32,
#[serde(default = "default_bar_radius")]
@@ -144,6 +241,7 @@ impl Default for ThemeGeometryConfig {
Self {
top_bar_height: default_bar_height(),
top_bar_margin_top: default_bar_margin_top(),
top_bar_window_gap: default_bar_window_gap(),
top_bar_margin_horizontal: default_bar_margin_horizontal(),
top_bar_radius: default_bar_radius(),
panel_padding: default_panel_padding(),
@@ -188,12 +286,18 @@ pub(crate) fn load() -> ShellConfig {
match toml::from_str::<FereseConfig>(&source) {
Ok(config) => {
let theme = shell_theme(&config.theme);
let mut theme = shell_theme(&config.theme);
theme.material_radius =
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
ShellConfig {
font_family: config.theme.typography.font_family,
wallpaper: config.theme.background,
theme,
status: StatusConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status
},
}
}
Err(error) => {
@@ -207,7 +311,31 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
let defaults = ShellTheme::default();
ShellTheme {
material_radius: defaults.material_radius,
bar_background: {
let mut background =
parse_color(&theme.surface.bar.background).unwrap_or(defaults.bar_background);
if let Some(opacity) = theme.surface.bar.opacity {
background[3] =
(finite_or(opacity, f32::from(background[3]) / 255.0).clamp(0.0, 1.0) * 255.0)
.round() as u8;
}
background
},
bar_text_primary: parse_color(&theme.surface.bar.text_primary)
.unwrap_or(defaults.bar_text_primary),
bar_text_muted: parse_color(&theme.surface.bar.text_muted)
.unwrap_or(defaults.bar_text_muted),
surface_base: parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base),
surface_popover: {
let mut color =
parse_color(&theme.colors.surface_base).unwrap_or(defaults.surface_base);
color[3] = (finite_or(theme.surface.popover.opacity, default_popover_opacity())
.clamp(0.0, 1.0)
* 255.0)
.round() as u8;
color
},
text_primary: parse_color(&theme.colors.text_primary).unwrap_or(defaults.text_primary),
text_muted: parse_color(&theme.colors.text_muted).unwrap_or(defaults.text_muted),
accent: parse_color(&theme.colors.accent).unwrap_or(defaults.accent),
@@ -215,13 +343,15 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
shadow: parse_color(&theme.colors.shadow).unwrap_or(defaults.shadow),
bar_height: positive_or(theme.geometry.top_bar_height, defaults.bar_height),
bar_margin_top: theme.geometry.top_bar_margin_top.max(0),
bar_window_gap: theme.geometry.top_bar_window_gap.max(0),
bar_margin_horizontal: theme.geometry.top_bar_margin_horizontal.max(0),
bar_radius: nonnegative_or(theme.geometry.top_bar_radius, defaults.bar_radius),
panel_padding: nonnegative_or(theme.geometry.panel_padding, defaults.panel_padding),
control_gap: nonnegative_or(theme.geometry.control_gap, defaults.control_gap),
shadow_offset_y: finite_or(theme.shadow.soft.offset_y, defaults.shadow_offset_y),
shadow_blur: nonnegative_or(theme.shadow.soft.blur, defaults.shadow_blur),
shadow_opacity: theme.shadow.soft.opacity.clamp(0.0, 1.0),
shadow_opacity: finite_or(theme.shadow.soft.opacity, defaults.shadow_opacity)
.clamp(0.0, 1.0),
}
}
@@ -282,22 +412,25 @@ fn default_shadow() -> String {
"#00000055".to_owned()
}
const fn default_bar_height() -> f32 {
38.0
28.0
}
const fn default_bar_window_gap() -> i32 {
8
}
const fn default_bar_margin_top() -> i32 {
4
0
}
const fn default_bar_margin_horizontal() -> i32 {
10
0
}
const fn default_bar_radius() -> f32 {
19.0
0.0
}
const fn default_panel_padding() -> f32 {
12.0
}
const fn default_control_gap() -> f32 {
8.0
12.0
}
const fn default_shadow_offset_y() -> f32 {
4.0
@@ -363,5 +496,47 @@ mod tests {
assert_eq!(config.theme.typography.font_family, None);
assert_eq!(config.theme.background.path, None);
assert_eq!(config.theme.background.mode, WallpaperMode::Fill);
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_margin_top, 0);
assert_eq!(theme.bar_height, 28.0);
assert_eq!(theme.bar_margin_horizontal, 0);
assert_eq!(theme.bar_radius, 0.0);
assert_eq!(theme.bar_background, ShellTheme::default().bar_background);
assert_eq!(theme.for_bar().text_primary, theme.bar_text_primary);
assert_ne!(theme.for_bar().text_primary, theme.text_primary);
}
#[test]
fn bar_palette_can_be_changed_without_changing_popovers() {
let config: FereseConfig = toml::from_str(
r##"
[theme.surface.bar]
background = "#EAECEEDD"
text_primary = "#222222"
text_muted = "#666666"
"##,
)
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background, [234, 236, 238, 221]);
assert_eq!(theme.for_bar().text_primary, [34, 34, 34, 255]);
assert_eq!(theme.for_bar().text_muted, [102, 102, 102, 255]);
assert_eq!(theme.text_primary, ShellTheme::default().text_primary);
}
#[test]
fn bar_transparency_and_shadow_opacity_are_independent() {
let config: FereseConfig = toml::from_str(
r#"
[theme.surface.bar]
opacity = 0.6
[theme.shadow.soft]
opacity = 0.07
"#,
)
.unwrap();
let theme = shell_theme(&config.theme);
assert_eq!(theme.bar_background[3], 153);
assert_eq!(theme.shadow_opacity, 0.07);
}
}
+5 -2
View File
@@ -140,8 +140,11 @@ fn control_connection() -> Result<Connection, Box<dyn Error>> {
// SAFETY: Ferese passes ownership of this inherited descriptor to the
// shell. This function is called once and consumes the descriptor.
let socket = unsafe { UnixStream::from_raw_fd(fd) };
Ok(Connection::from_socket(socket)?)
// Rust's cloned stream is close-on-exec. Do not leak shell-control authority
// into status helpers or applications launched from the menu.
let connection_socket = socket.try_clone()?;
drop(socket);
Ok(Connection::from_socket(connection_socket)?)
}
struct ControlState {
+416 -74
View File
@@ -1,7 +1,16 @@
mod config;
mod control;
mod motion;
mod status;
mod status_ui;
use std::time::Duration;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
// Older libcosmic backends expose native surfaces in frame events. Keep
// that compatibility path gated; current backends use Window::Opened and
// window::run to retrieve handles without updates at the display refresh rate.
static EFFECT_FRAME_PENDING: AtomicBool = AtomicBool::new(true);
use cosmic::Element;
use cosmic::app::{Core, Settings, Task};
@@ -39,6 +48,7 @@ struct BarMetrics {
height: f32,
text_size: u16,
icon_size: u16,
overview_icon_size: u16,
control_height: f32,
workspace_hit_width: f32,
active_marker_width: f32,
@@ -53,14 +63,17 @@ impl From<ShellTheme> for BarMetrics {
Self {
height,
text_size: (height * 0.368).round() as u16,
icon_size: (height * 0.553).round() as u16,
control_height: height * 0.79,
workspace_hit_width: height * 0.68,
active_marker_width: height * 0.47,
active_marker_height: height * 0.13,
dot_marker_size: height * 0.18,
empty_marker_size: height * 0.13,
// Logical sizes: Wayland/iced applies each surface's output scale.
// Compacting the bar must not also shrink its readable content.
text_size: 13,
icon_size: 19,
overview_icon_size: 19,
control_height: (height - 4.0).max(21.0).min(height),
workspace_hit_width: 22.0,
active_marker_width: 14.0,
active_marker_height: 4.0,
dot_marker_size: 4.0,
empty_marker_size: 3.0,
}
}
}
@@ -90,14 +103,28 @@ struct FereseShell {
overview_active: bool,
clock: String,
effects: Option<EffectsBinding>,
status_service: status::Service,
status: status::Snapshot,
status_error: Option<String>,
menu: Option<status_ui::OpenMenu>,
}
#[derive(Clone, Debug)]
enum Message {
Event(Event, window::Id),
NativeSurface(
window::Id,
Result<(Connection, wl_surface::WlSurface), String>,
),
Tick,
ActivateWorkspace(u64),
ToggleOverview,
StatusTick,
AnimateMenu,
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
Control(status::Action),
ConfirmPower(status::Action),
CancelPower,
}
impl cosmic::Application for FereseShell {
@@ -123,6 +150,10 @@ impl cosmic::Application for FereseShell {
let app = Self {
core,
bar_surface_id,
status_service: status::Service::start(config.status.settings_command.clone()),
status: status::Snapshot::default(),
status_error: None,
menu: None,
config,
control: ShellControl::connect()
.map_err(|error| {
@@ -167,7 +198,10 @@ impl cosmic::Application for FereseShell {
},
size: Some((None, Some(bar.height.round() as u32))),
size_limits: Limits::NONE,
exclusive_zone: bar.height.round() as i32,
// Reserve breathing room below the visible bar; layer-shell
// accounts for the top margin separately.
exclusive_zone: (bar.height.round() as i32)
.saturating_add(shell_theme.bar_window_gap),
..Default::default()
},
Some(Box::new(Self::view_layer)),
@@ -182,13 +216,92 @@ impl cosmic::Application for FereseShell {
fn subscription(&self) -> Subscription<Self::Message> {
Subscription::batch([
event::listen_with(|event, _status, id| Some(Message::Event(event, id))),
cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::Tick),
event::listen_with(|event, _status, id| match &event {
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => {
EFFECT_FRAME_PENDING
.load(Ordering::Relaxed)
.then_some(Message::Event(event, id))
}
Event::Keyboard(_)
| Event::Window(window::Event::Opened { .. } | window::Event::Closed)
| Event::PlatformSpecific(PlatformSpecific::Wayland(
wayland::Event::Popup(..) | wayland::Event::Layer(..),
)) => Some(Message::Event(event, id)),
_ => None,
}),
cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick),
cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::StatusTick),
if self.menu.as_ref().is_some_and(|menu| menu.animating()) {
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
} else {
Subscription::none()
},
])
}
fn update(&mut self, message: Self::Message) -> Task<Self::Message> {
match message {
Message::NativeSurface(id, result) => {
match result {
Ok((_connection, surface)) if id == self.bar_surface_id => {
self.attach_effects(&surface)
}
Ok((_connection, surface)) => {
if let Some(menu) = &mut self.menu
&& menu.id == id
&& menu.effects.is_none()
{
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => {
eprintln!("ferese-shell: popover material unavailable: {error}")
}
}
}
}
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
}
Task::none()
}
Message::StatusTick => {
if let Some(update) = self.status_service.poll()
&& update.generation >= self.status_service.generation
{
self.status = update.snapshot;
self.status_error = update.error;
}
Task::none()
}
Message::AnimateMenu => Task::none(),
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
Message::ConfirmPower(action) => {
if let Some(menu) = &mut self.menu {
menu.confirm = Some(action);
}
Task::none()
}
Message::CancelPower => {
if let Some(menu) = &mut self.menu {
menu.confirm = None;
}
Task::none()
}
Message::Control(action) => {
self.status_error = self.status_service.send(action.clone()).err();
if self.status_error.is_none() {
self.optimistic_status(&action);
}
if matches!(
action,
status::Action::Notifications | status::Action::Settings
) {
return self.destroy_menu();
}
Task::none()
}
Message::Event(event, id) => self.handle_event(event, id),
Message::Tick => {
self.clock = current_time();
@@ -216,7 +329,10 @@ impl cosmic::Application for FereseShell {
let input_zone = if hidden { Some(Vec::new()) } else { None };
return set_input_zone(self.bar_surface_id, input_zone);
return Task::batch([
set_input_zone(self.bar_surface_id, input_zone),
self.destroy_menu(),
]);
}
}
}
@@ -238,6 +354,12 @@ impl cosmic::Application for FereseShell {
}
}
fn style(&self) -> Option<cosmic::iced::theme::Style> {
// Shell surfaces own their backgrounds. Never inherit an opaque
// application clear color during startup or a system-theme change.
Some(shell_surface_style(self.config.theme))
}
fn view(&self) -> Element<'_, Self::Message> {
text("").into()
}
@@ -250,9 +372,94 @@ impl cosmic::Application for FereseShell {
impl FereseShell {
fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
match event {
Event::Window(window::Event::Opened { .. })
if id == self.bar_surface_id
|| self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
{
window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
}
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape),
..
}) if self.menu.is_some() => self.destroy_menu(),
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
event,
surface,
popup_id,
))) => {
if let Some(menu) = &mut self.menu
&& menu.id == popup_id
{
match event {
wayland::PopupEvent::Done => {
self.menu = None;
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
}
wayland::PopupEvent::Focused => {
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => eprintln!(
"ferese-shell: popover material unavailable: {error}"
),
}
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
}
}
wayland::PopupEvent::Configured { .. } => {
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => eprintln!(
"ferese-shell: popover material unavailable: {error}"
),
}
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
}
}
_ => {}
}
}
Task::none()
}
Event::Window(window::Event::Closed)
if self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
{
self.menu = None;
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
Task::none()
}
Event::Window(window::Event::Closed) if id == self.bar_surface_id => {
cosmic::iced::exit()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
_,
surface,
frame_id,
))) if self.menu.as_ref().is_some_and(|menu| menu.id == frame_id) => {
if let Some(menu) = &mut self.menu {
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
Some(ferese_surface_effects_v1::Role::Popover),
) {
Ok(binding) => menu.effects = Some(binding),
Err(error) => {
eprintln!("ferese-shell: popover material unavailable: {error}")
}
}
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
}
}
Task::none()
}
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(
_,
surface,
@@ -278,6 +485,8 @@ impl FereseShell {
return;
}
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
match EffectsBinding::attach(surface, !bar_hidden(&self.snapshot)) {
Ok(binding) => self.effects = Some(binding),
Err(error) => eprintln!("ferese-shell: panel material unavailable: {error}"),
@@ -299,24 +508,28 @@ impl FereseShell {
.find(|output| output.focused)
.or_else(|| self.snapshot.outputs.first());
let focused_output_id = focused_output.map(|output| output.id);
let shell_theme = self.config.theme;
let shell_theme = self.config.theme.for_bar();
let bar = BarMetrics::from(shell_theme);
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
.spacing(1)
.align_y(cosmic::iced::Alignment::Center);
workspace_row = workspace_row.push(
button::custom(overview_control(bar, shell_theme))
button::custom(overview_control(bar, color(shell_theme.text_primary)))
.height(bar.control_height)
.padding(0)
.class(theme::Button::Transparent)
.padding([0, 7])
.class(status_ui::button_style(
color(shell_theme.text_primary),
self.overview_active,
1.0,
))
.on_press(cosmic::Action::App(Message::ToggleOverview)),
);
for workspace in self
.snapshot
.workspaces
.iter()
.filter(|workspace| workspace.output == focused_output_id)
.filter(|workspace| self.overview_active && workspace.output == focused_output_id)
{
let occupied = self
.snapshot
@@ -327,7 +540,11 @@ impl FereseShell {
let workspace_button = button::custom(indicator)
.height(bar.control_height)
.padding(0)
.class(theme::Button::Transparent)
.class(status_ui::button_style(
color(shell_theme.text_primary),
false,
1.0,
))
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
workspace.id,
)));
@@ -346,36 +563,49 @@ impl FereseShell {
})
.map(display_app_name)
.unwrap_or_else(|| "Desktop".to_owned());
let left = row![workspace_row, text(app_name).size(bar.text_size)]
.spacing(shell_theme.control_gap)
.align_y(cosmic::iced::Alignment::Center);
let center = text(&self.clock).size(bar.text_size);
let right = row![
symbolic_icon(include_bytes!("../assets/icons/wifi.svg"), bar.icon_size),
symbolic_icon(include_bytes!("../assets/icons/volume.svg"), bar.icon_size),
symbolic_icon(include_bytes!("../assets/icons/battery.svg"), bar.icon_size),
let foreground = color(shell_theme.text_primary);
let app_name = if self.overview_active {
"Overview".to_owned()
} else {
app_name
};
let left = row![
workspace_row,
text(app_name)
.size(bar.text_size)
.font(cosmic::font::semibold())
.width(Length::Fill)
.wrapping(cosmic::iced::core::text::Wrapping::None)
.class(theme::Text::Color(foreground))
]
.spacing(shell_theme.control_gap)
.spacing(8)
.align_y(cosmic::iced::Alignment::Center);
let clock = container(
text(&self.clock)
.size(bar.text_size)
.wrapping(cosmic::iced::core::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
)
.padding([0, 6]);
let right = row![self.view_status_bar(), clock]
.spacing(10)
.align_y(cosmic::iced::Alignment::Center);
let content = row![
container(left)
.width(Length::FillPortion(1))
.align_x(alignment::Horizontal::Left),
container(center)
.width(Length::FillPortion(1))
.align_x(alignment::Horizontal::Center),
container(right)
.width(Length::FillPortion(1))
.align_x(alignment::Horizontal::Right),
container(left).width(Length::Fill),
container(right).width(Length::Shrink),
]
.spacing(16)
.align_y(cosmic::iced::Alignment::Center)
.height(Length::Fill);
let compositor_material = self.effects.is_some();
container(content)
.width(Length::Fill)
.height(Length::Fill)
.padding([0, shell_theme.panel_padding.round() as u16])
.class(theme::Container::custom(move |_| bar_style(shell_theme)))
.class(theme::Container::custom(move |_| {
bar_style(shell_theme, compositor_material)
}))
.into()
}
@@ -392,11 +622,16 @@ impl FereseShell {
WallpaperMode::Fit => ContentFit::Contain,
};
image(path.clone())
.width(Length::Fill)
.height(Length::Fill)
.content_fit(content_fit)
.into()
container(
image(path.clone())
.width(Length::Fill)
.height(Length::Fill)
.content_fit(content_fit),
)
.width(Length::Fill)
.height(Length::Fill)
.class(theme::Container::custom(wallpaper_fallback_style))
.into()
}
}
@@ -407,29 +642,29 @@ fn symbolic_icon(source: &'static [u8], size: u16) -> icon::Icon {
.size(size)
}
fn bar_icon(source: &'static [u8], size: u16, foreground: Color) -> icon::Icon {
// COSMIC's transparent containers replace inherited foreground colors.
// Pin the bar glyph color rather than relying on those container defaults.
symbolic_icon(source, size).class(theme::Svg::custom(move |_| {
cosmic::iced::widget::svg::Style {
color: Some(foreground),
}
}))
}
fn overview_control(
bar: BarMetrics,
shell_theme: ShellTheme,
foreground: Color,
) -> Element<'static, cosmic::Action<Message>> {
container(symbolic_icon(
include_bytes!("../assets/icons/overview.svg"),
bar.icon_size,
container(bar_icon(
include_bytes!("../assets/icons/ferese.svg"),
bar.overview_icon_size,
foreground,
))
.width(bar.control_height)
.width(bar.overview_icon_size)
.height(bar.control_height)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center)
.class(theme::Container::custom(move |_| container::Style {
icon_color: Some(color(shell_theme.text_primary)),
background: Some(Background::Color(color(shell_theme.accent))),
border: Border {
color: color_with_opacity(shell_theme.text_primary, 0.14),
width: 1.0,
radius: (bar.control_height * 0.34).into(),
},
snap: true,
..container::Style::default()
}))
.into()
}
@@ -523,28 +758,34 @@ fn display_app_name(window: &control::WindowSnapshot) -> String {
}
fn current_time() -> String {
Zoned::now().strftime("%H:%M").to_string()
Zoned::now().strftime("%a · %H:%M").to_string()
}
fn bar_style(theme: ShellTheme) -> container::Style {
let mut shadow = color(theme.shadow);
shadow.a *= theme.shadow_opacity;
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
cosmic::iced::theme::Style {
background_color: Color::TRANSPARENT,
text_color: color(theme.text_primary),
icon_color: color(theme.text_primary),
}
}
fn bar_style(theme: ShellTheme, compositor_material: bool) -> container::Style {
container::Style {
icon_color: Some(color(theme.text_primary)),
text_color: Some(color(theme.text_primary)),
background: None,
border: Border {
color: color(theme.border),
width: 1.0,
radius: theme.bar_radius.into(),
// The compositor paints the selected panel material behind this surface.
// A client-side fill would cover its blur and tint; use it only as fallback.
background: if compositor_material {
None
} else {
Some(Background::Color(color(theme.bar_background)))
},
shadow: Shadow {
color: shadow,
offset: Vector::new(0.0, theme.shadow_offset_y),
blur_radius: theme.shadow_blur,
border: Border {
radius: theme.bar_radius.into(),
..Border::default()
},
snap: true,
..container::Style::default()
}
}
@@ -566,6 +807,39 @@ fn wallpaper_fallback_style(_theme: &cosmic::Theme) -> container::Style {
}
}
// Resolve a surface while iced guarantees the native handles are alive. Keep
// the borrowed-display connection alive until the effects binding takes over.
fn native_wayland_surface(
window: &dyn cosmic::iced::window::Window,
) -> Result<(Connection, wl_surface::WlSurface), String> {
use cosmic::iced::window::raw_window_handle::{RawDisplayHandle, RawWindowHandle};
let display = window.display_handle().map_err(|error| error.to_string())?;
let handle = window.window_handle().map_err(|error| error.to_string())?;
let (RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(handle)) =
(display.as_raw(), handle.as_raw())
else {
return Err("shell material requires a Wayland surface".to_owned());
};
// SAFETY: iced lends matching, live Wayland handles for this callback.
// This backend borrows the display; it never disconnects iced's connection.
// Both handles belong to the runtime, which outlives the shell bindings.
let backend = unsafe {
wayland_client::backend::Backend::from_foreign_display(display.display.as_ptr().cast())
};
let connection = Connection::from_backend(backend);
// SAFETY: raw-window-handle identifies this pointer as a live wl_surface.
let id = unsafe {
wayland_client::backend::ObjectId::from_ptr(
wl_surface::WlSurface::interface(),
handle.surface.as_ptr().cast(),
)
}
.map_err(|error| error.to_string())?;
let surface =
wl_surface::WlSurface::from_id(&connection, id).map_err(|error| error.to_string())?;
Ok((connection, surface))
}
struct EffectsBinding {
connection: Connection,
_manager: FereseEffectsManagerV1,
@@ -577,6 +851,16 @@ impl EffectsBinding {
fn attach(
surface: &wl_surface::WlSurface,
visible: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
Self::attach_role(
surface,
visible.then_some(ferese_surface_effects_v1::Role::Panel),
)
}
fn attach_role(
surface: &wl_surface::WlSurface,
role: Option<ferese_surface_effects_v1::Role>,
) -> Result<Self, Box<dyn std::error::Error>> {
let backend = surface
.backend()
@@ -588,8 +872,8 @@ impl EffectsBinding {
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=1, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
if visible {
effects.set_role(ferese_surface_effects_v1::Role::Panel);
if let Some(role) = role {
effects.set_role(role);
} else {
effects.clear_role();
}
@@ -638,6 +922,64 @@ mod tests {
use super::*;
use crate::control::{OutputSnapshot, WindowSnapshot};
#[test]
fn startup_surface_clear_is_transparent_and_bar_fallback_is_dark() {
let theme = ShellTheme::default();
assert_eq!(
shell_surface_style(theme).background_color,
Color::TRANSPARENT
);
let Some(Background::Color(background)) = bar_style(theme.for_bar(), false).background
else {
panic!("first frame needs a fallback fill before material attachment");
};
assert!(background.r < 0.15 && background.g < 0.15 && background.b < 0.20);
assert!(background.a > 0.9);
assert!(
theme.bar_text_primary[..3]
.iter()
.all(|channel| *channel > 220)
);
}
#[test]
fn bar_fallback_uses_the_matching_dark_palette_and_geometry() {
let theme = ShellTheme::default().for_bar();
let style = bar_style(theme, false);
assert_eq!(
style.background,
Some(Background::Color(color(theme.bar_background)))
);
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
assert_eq!(style.border.radius, theme.bar_radius.into());
assert_eq!(style.shadow, cosmic::iced::Shadow::default());
}
#[test]
fn bar_does_not_cover_the_selected_compositor_material() {
let theme = ShellTheme::default().for_bar();
let style = bar_style(theme, true);
assert_eq!(style.background, None);
assert_eq!(style.text_color, Some(color(theme.bar_text_primary)));
assert_eq!(style.border.radius, theme.bar_radius.into());
assert_eq!(style.shadow, cosmic::iced::Shadow::default());
}
#[test]
fn compact_bar_preserves_logical_text_and_icon_sizes() {
for height in [24.0, 26.0, 32.0, 38.0, 44.0] {
let metrics = BarMetrics::from(ShellTheme {
bar_height: height,
..ShellTheme::default()
});
assert_eq!(metrics.text_size, 13);
assert_eq!(metrics.icon_size, 19);
assert_eq!(metrics.overview_icon_size, 19);
assert!(metrics.control_height >= f32::from(metrics.icon_size));
assert!(metrics.control_height <= height);
}
}
#[test]
fn fullscreen_on_the_active_workspace_hides_the_bar() {
let snapshot = snapshot_with_fullscreen_window(7);
+115
View File
@@ -0,0 +1,115 @@
//! Draw-only popup transform; layout stays stable while its presentation animates.
use cosmic::iced::advanced::Renderer as _;
use cosmic::iced::advanced::{Clipboard, Layout, Shell, Widget, layout, mouse, renderer, widget};
use cosmic::iced::{Event, Length, Rectangle, Size, Transformation, Vector};
use cosmic::{Element, Theme};
pub fn animated<'a, M: 'a>(content: Element<'a, M>, progress: f32) -> Element<'a, M> {
Element::new(Motion { content, progress })
}
struct Motion<'a, M> {
content: Element<'a, M>,
progress: f32,
}
impl<M> Motion<'_, M> {
fn translation(&self) -> Vector {
Vector::new(0.0, -4.0 * (1.0 - self.progress))
}
fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor {
cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
mouse::Cursor::Available(p - self.translation())
})
}
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
fn tag(&self) -> widget::tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> widget::tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<widget::Tree> {
self.content.as_widget().children()
}
fn diff(&mut self, tree: &mut widget::Tree) {
self.content.as_widget_mut().diff(tree);
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &cosmic::Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &cosmic::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, M>,
viewport: &Rectangle,
) {
let cursor = self.cursor(cursor);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut cosmic::Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
let v = self.translation();
let transform = Transformation::translate(v.x, v.y);
renderer.with_transformation(transform, |renderer| {
self.content.as_widget().draw(
tree,
renderer,
theme,
style,
layout,
self.cursor(cursor),
viewport,
)
});
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &cosmic::Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
tree,
layout,
self.cursor(cursor),
viewport,
renderer,
)
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
}
+463
View File
@@ -0,0 +1,463 @@
//! Bounded, off-UI-thread adapters. A missing service is `None`, never a fake state.
use std::{
env, fs,
path::Path,
process::{Command, Stdio},
sync::{
Arc, Condvar, Mutex,
mpsc::{self, Receiver, SyncSender},
},
thread,
time::Duration,
};
#[derive(Clone, Debug, Default)]
pub struct Snapshot {
pub network: Option<Network>,
pub bluetooth: Option<Bluetooth>,
pub audio: Option<Audio>,
pub battery: Option<Battery>,
pub brightness: Option<u8>,
pub notifications: Option<Notifications>,
pub poweroff: bool,
pub reboot: bool,
pub suspend: bool,
}
#[derive(Clone, Debug)]
pub struct Network {
pub enabled: bool,
pub connection: Option<String>,
pub signal: u8,
}
#[derive(Clone, Debug)]
pub struct Bluetooth {
pub enabled: bool,
pub devices: Vec<String>,
}
#[derive(Clone, Debug)]
pub struct Audio {
pub volume: u8,
pub muted: bool,
pub output: String,
}
#[derive(Clone, Debug)]
pub struct Battery {
pub percent: u8,
pub status: String,
}
#[derive(Clone, Debug)]
pub struct Notifications {
pub count: u32,
pub dnd: bool,
}
#[derive(Clone, Debug)]
pub enum Action {
Wifi(bool),
Bluetooth(bool),
Volume(u8),
Mute(bool),
Brightness(u8),
Dnd(bool),
Notifications,
Settings,
Poweroff,
Reboot,
Suspend,
}
impl Action {
fn key(&self) -> std::mem::Discriminant<Self> {
std::mem::discriminant(self)
}
}
pub struct Update {
pub snapshot: Snapshot,
pub generation: u64,
pub error: Option<String>,
}
pub struct Service {
tx: SyncSender<(u64, Action)>,
rx: Receiver<Update>,
pub generation: u64,
}
impl Service {
pub fn start(settings: Option<Vec<String>>) -> Self {
let (tx, commands) = mpsc::sync_channel::<(u64, Action)>(64);
let (updates, rx) = mpsc::sync_channel(1);
// Polling and writes have separate workers: a missing D-Bus service must
// never hold up volume/brightness changes. Publish only coherent polls.
let shared = Arc::new((
Mutex::new((0u64, false, None::<String>, false)),
Condvar::new(),
));
let polling = shared.clone();
thread::spawn(move || {
loop {
let before = {
let state = polling.0.lock().unwrap();
if state.3 {
break;
}
state.0
};
let snapshot = poll();
let state = polling.0.lock().unwrap();
if state.3 {
break;
}
if state.0 == before && !state.1 {
let _ = updates.try_send(Update {
snapshot,
generation: state.0,
error: state.2.clone(),
});
let _ = polling.1.wait_timeout(state, Duration::from_secs(2));
}
}
});
thread::spawn(move || {
loop {
let first = match commands.recv() {
Ok(command) => command,
Err(_) => break,
};
let mut pending = vec![first];
for command in commands.try_iter() {
pending.retain(|(_, action)| action.key() != command.1.key());
pending.push(command);
}
for (id, action) in pending {
{
let mut state = shared.0.lock().unwrap();
state.0 = id;
state.1 = true;
}
let error = execute(&action, settings.as_deref()).err();
let mut state = shared.0.lock().unwrap();
state.1 = false;
state.2 = error;
shared.1.notify_one();
}
}
shared.0.lock().unwrap().3 = true;
shared.1.notify_one();
});
Self {
tx,
rx,
generation: 0,
}
}
pub fn send(&mut self, action: Action) -> Result<(), String> {
let generation = self.generation + 1;
self.tx
.try_send((generation, action))
.map_err(|_| "Controls are busy; please try again".to_owned())?;
self.generation = generation;
Ok(())
}
pub fn poll(&self) -> Option<Update> {
self.rx.try_iter().last()
}
}
pub fn available(program: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
let executable = |p: &Path| {
p.metadata()
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
};
if program.contains('/') {
return executable(Path::new(program));
}
env::var_os("PATH")
.is_some_and(|paths| env::split_paths(&paths).any(|path| executable(&path.join(program))))
}
fn run(program: &str, args: &[&str]) -> Result<String, String> {
if !available(program) {
return Err(format!("{program} is not installed"));
}
// Coreutils timeout bounds disconnected D-Bus services, too. Never invoke a shell.
let output = Command::new("timeout")
.args(["--kill-after=1s", "2s", program])
.args(args)
.env("LC_ALL", "C")
.stdin(Stdio::null())
.output()
.map_err(|e| e.to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr)
.trim()
.chars()
.take(180)
.collect::<String>();
return Err(if message.is_empty() {
format!("{program} did not complete successfully")
} else {
message
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn poll() -> Snapshot {
Snapshot {
network: network(),
bluetooth: bluetooth(),
audio: audio(),
battery: battery(),
brightness: brightness(),
notifications: notifications(),
poweroff: can_power("CanPowerOff"),
reboot: can_power("CanReboot"),
suspend: can_power("CanSuspend"),
}
}
fn network() -> Option<Network> {
// No Wi-Fi device means no Wi-Fi control (wired connectivity isn't called Wi-Fi).
let devices = run("nmcli", &["-t", "-f", "TYPE", "device", "status"]).ok()?;
if !devices.lines().any(|line| line == "wifi") {
return None;
}
let enabled = run("nmcli", &["radio", "wifi"]).ok()? == "enabled";
let mut connection = None;
let mut signal = 0;
if enabled {
let rows = run(
"nmcli",
&[
"-t",
"-f",
"IN-USE,SIGNAL,SSID",
"device",
"wifi",
"list",
"--rescan",
"no",
],
)
.ok()?;
for line in rows.lines() {
if let Some((strength, ssid)) = line.strip_prefix("*:").and_then(|s| s.split_once(':'))
{
signal = strength.parse::<u8>().ok()?.min(100);
connection = Some(ssid.replace("\\:", ":").replace("\\\\", "\\"));
break;
}
}
}
Some(Network {
enabled,
connection,
signal,
})
}
fn bluetooth() -> Option<Bluetooth> {
let info = run("bluetoothctl", &["show"]).ok()?;
let enabled = info
.lines()
.find_map(|line| line.trim().strip_prefix("Powered: "))?
== "yes";
let devices = if enabled {
run("bluetoothctl", &["devices", "Connected"])
.ok()?
.lines()
.filter_map(|line| {
line.strip_prefix("Device ")
.and_then(|s| s.split_once(' '))
.map(|(_, name)| name.to_owned())
})
.collect()
} else {
Vec::new()
};
Some(Bluetooth { enabled, devices })
}
pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
let volume = value
.strip_prefix("Volume: ")?
.split_whitespace()
.next()?
.parse::<f32>()
.ok()?;
if !volume.is_finite() || volume < 0.0 {
return None;
}
Some((
(volume * 100.0).round().clamp(0.0, 100.0) as u8,
value.contains("[MUTED]"),
))
}
fn audio() -> Option<Audio> {
let (volume, muted) =
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
let info = run("wpctl", &["inspect", "@DEFAULT_AUDIO_SINK@"]).ok()?;
let output = info
.lines()
.find_map(|line| line.trim().strip_prefix("node.description = "))
.map(|s| s.trim_matches('"').to_owned())
.unwrap_or_else(|| "Default output".into());
Some(Audio {
volume,
muted,
output,
})
}
fn read(path: &Path, name: &str) -> Option<String> {
fs::read_to_string(path.join(name))
.ok()
.map(|s| s.trim().to_owned())
}
fn battery() -> Option<Battery> {
let entries = fs::read_dir("/sys/class/power_supply").ok()?;
// Prefer a system battery, not a mouse/headset battery.
let mut batteries = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
read(p, "type").as_deref() == Some("Battery")
&& read(p, "scope").as_deref() != Some("Device")
})
.collect::<Vec<_>>();
batteries.sort();
batteries.iter().find_map(|path| {
Some(Battery {
percent: read(path, "capacity")?.parse::<u8>().ok()?.min(100),
status: read(path, "status")?,
})
})
}
fn brightness() -> Option<u8> {
let max = run("brightnessctl", &["--class=backlight", "max"])
.ok()?
.parse::<f64>()
.ok()?;
let current = run("brightnessctl", &["--class=backlight", "get"])
.ok()?
.parse::<f64>()
.ok()?;
(max > 0.0 && current.is_finite())
.then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8)
}
fn notifications() -> Option<Notifications> {
// Check service ownership first: swaync-client otherwise waits indefinitely.
let owner = run(
"busctl",
&[
"--user",
"call",
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
"NameHasOwner",
"s",
"org.erikreider.swaync",
],
)
.ok()?;
if owner != "b true" {
return None;
}
let count = run("swaync-client", &["--count"]).ok()?.parse().ok()?;
let dnd = match run("swaync-client", &["--get-dnd"]).ok()?.as_str() {
"true" => true,
"false" => false,
_ => return None,
};
Some(Notifications { count, dnd })
}
fn can_power(method: &str) -> bool {
run(
"busctl",
&[
"--system",
"call",
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
method,
],
)
.is_ok_and(|s| s == "s \"yes\"" || s == "s \"challenge\"")
}
fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
match action {
Action::Wifi(on) => run("nmcli", &["radio", "wifi", if *on { "on" } else { "off" }]),
Action::Bluetooth(on) => run("bluetoothctl", &["power", if *on { "on" } else { "off" }]),
Action::Volume(value) => run(
"wpctl",
&[
"set-volume",
"-l",
"1.0",
"@DEFAULT_AUDIO_SINK@",
&format!("{}%", value.min(&100)),
],
),
Action::Mute(on) => run(
"wpctl",
&[
"set-mute",
"@DEFAULT_AUDIO_SINK@",
if *on { "1" } else { "0" },
],
),
Action::Brightness(value) => run(
"brightnessctl",
&[
"--class=backlight",
"set",
&format!("{}%", value.clamp(&1, &100)),
],
),
Action::Dnd(on) => run(
"swaync-client",
&[if *on { "--dnd-on" } else { "--dnd-off" }],
),
Action::Notifications => run("swaync-client", &["--open-panel"]),
Action::Poweroff => run("systemctl", &["poweroff"]),
Action::Reboot => run("systemctl", &["reboot"]),
Action::Suspend => run("systemctl", &["suspend"]),
Action::Settings => {
let argv = settings
.filter(|argv| !argv.is_empty())
.ok_or("No settings application configured")?;
let mut command = Command::new(&argv[0]);
command
.args(&argv[1..])
.stdin(Stdio::null())
.env_remove("WAYLAND_SOCKET")
.env_remove("FERESE_SHELL_CONTROL_SOCKET");
if let Some(display) = env::var_os("FERESE_PUBLIC_WAYLAND_DISPLAY") {
command.env("WAYLAND_DISPLAY", display);
}
command.env_remove("FERESE_PUBLIC_WAYLAND_DISPLAY");
let mut child = command.spawn().map_err(|e| e.to_string())?;
thread::spawn(move || {
let _ = child.wait();
});
return Ok(());
}
}
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn audio_parser_handles_mute_and_rejects_invalid_values() {
assert_eq!(parse_audio("Volume: 0.42 [MUTED]"), Some((42, true)));
assert_eq!(parse_audio("Volume: 1.2"), Some((100, false)));
for value in ["", "Volume: NaN", "Volume: -1", "error"] {
assert_eq!(parse_audio(value), None);
}
}
#[test]
fn default_snapshot_does_not_invent_services() {
let s = Snapshot::default();
assert!(s.network.is_none() && s.audio.is_none() && s.notifications.is_none());
assert!(!s.poweroff && !s.reboot && !s.suspend);
}
}
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -64,6 +64,9 @@ fn main() -> Result<(), Box<dyn Error>> {
] {
effects.set_role(role);
queue.roundtrip(&mut state)?;
// Exercise each role in an actual presented frame, including changes
// to the cached backdrop allocation and shadow bounds.
std::thread::sleep(Duration::from_millis(50));
if state.configure_count != initial_configures {
return Err(format!("semantic role {role:?} changed layer geometry").into());
}
@@ -210,8 +213,11 @@ fn create_buffer(
let size = stride * height;
let mut file = tempfile::tempfile()?;
file.set_len(u64::from(size))?;
let alpha = if preview { 0x28 } else { 0xd8 };
let pixel = [0x28_u8, 0x24, 0x20, alpha];
let pixel = if preview {
[0_u8; 4]
} else {
[0x28, 0x24, 0x20, 0xd8]
};
for _ in 0..width * height {
file.write_all(&pixel)?;
}