Add live config reload, session locking and managed daemons

Refine focus gradients, shell workspace controls and popup motion. Correct floating stacking and preserve client-chosen dialog sizes. Add lock protocol probes and session integration.
This commit is contained in:
Samuel Onoja committed 2026-09-26 19:03:37 +01:00
1 parent b422448d88
commit 672b4e2b60
41 files changed
+2694 -298

No files matched your search

Generated
+10
View File
@@ -1580,6 +1580,7 @@ dependencies = [
"fontdb",
"image",
"libc",
"notify",
"serde",
"serde_json",
"smithay",
@@ -1625,6 +1626,14 @@ dependencies = [
name = "ferese-layout"
version = "0.1.0"
[[package]]
name = "ferese-lock-probe"
version = "0.1.0"
dependencies = [
"wayland-client",
"wayland-protocols",
]
[[package]]
name = "ferese-malformed-client"
version = "0.1.0"
@@ -1645,6 +1654,7 @@ name = "ferese-shell"
version = "0.1.0"
dependencies = [
"ferese-protocols",
"iced_accessibility",
"jiff",
"libcosmic",
"serde",
+1
View File
@@ -11,6 +11,7 @@ members = [
"tools/ferese-effects-probe",
"tools/ferese-malformed-client",
"tools/ferese-shell-control-probe",
"tools/ferese-lock-probe",
"tools/ferese-shell-spike",
]
resolver = "3"
+84 -2
View File
@@ -11,6 +11,55 @@ For a versioned installation alongside Plasma in SDDM, see
[installation and recovery](docs/installation.md). `Super+Shift+E` or
`feresectl exit` logs out immediately; save your work first.
## Live configuration
Saving `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/config.toml`)
reloads it automatically. Atomic editor saves are supported and bursts are
debounced. Invalid edits keep the last working compositor and shell config;
the rejection is logged. Idle watching does not reread the file.
Use `feresectl reload-config` to reload manually and report validation errors.
Live sources are limited to 60 KiB for safe delivery over the private protocol.
Appearance, fonts, wallpaper, animations, keybindings, keyboard layout/repeat,
gaps, scrolling focus policy/default widths, and bar geometry reload live.
Wallpaper decoding stays off the render thread and keeps the old image until
the replacement is ready. Existing windows, widths and layouts are preserved;
layout-mode defaults and initial window rules affect new workspaces/windows.
Output profiles and touchpad changes apply on reconnect or session restart.
The compositor and shell need one initial update/restart to enable this feature.
## Session services and locking
`[[autostart]]` entries run after the compositor's public socket is ready:
```toml
[[autostart]]
command = ["awari"]
restart = true
[[autostart]]
command = ["swayidle", "-w", "timeout", "600", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"]
restart = true
```
Services are session-owned, reaped, and restarted with a five-second retry delay.
They receive the correct public Wayland display without private shell privileges.
Nested previews skip them unless `nested = true` is explicitly configured.
Autostart changes require a session restart; live config edits do not restart services.
Run foreground daemons here, not commands that fork themselves into the background.
Ferese implements `ext-session-lock-v1`. While locked, only lock surfaces over an
opaque fallback are rendered, normal input/shortcuts and IPC are blocked, and
screencopy is unavailable. DRM confirmation waits for safe page flips on active
outputs. A missing/crashed locker leaves the session locked; it is not an unlock.
`ferese-lock` runs swaylock/swaylock-effects with optional
`~/.config/ferese/swaylock.conf`, waiting for lock confirmation before daemonizing.
The compositor authenticates no passwords itself; authentication belongs to the locker.
Validate password unlock in a disposable nested session before enabling automatic
locking on a primary session. Crash recovery currently requires ending that session
from another VT; there is deliberately no unauthenticated IPC unlock.
## Development
Install the native dependencies required by Smithay's winit and GLES backends,
@@ -119,6 +168,11 @@ duration_ms = 150 # 0 snaps immediately; reduced motion also snaps
```
Inactive dimming follows window focus with a smooth, interruption-safe fade.
Focus-ring paint (including gradients) and width use the same `duration_ms`,
even when dimming is disabled. Focus changes never scale or move the window.
Shell buttons fade their hover tint over 120 ms. Both transitions respect
`animations.speed`, `animations.enabled`, and `animations.reduced_motion`;
click actions remain immediate and settled hover states do not animate.
It keeps window opacity intact, respects animated geometry and rounded corners,
and leaves shell bars/popovers untouched. Overview temporarily removes dimming.
@@ -198,6 +252,28 @@ out over 80 ms, with a 64 MiB total snapshot budget and automatic release.
All monitors share one elapsed-time animation clock, and window content,
clips, borders and shadows use the same physical-pixel edges.
Window borders and focus rings use solid colors by default. Optional linear
gradients can be configured independently:
```toml
[theme.focus_ring.gradient]
from = "#e5c890"
to = "#b98d58"
angle = 135.0
# Optional unfocused-window gradient:
[theme.border.gradient]
from = "#3e3e43"
to = "#242429"
angle = 90.0
```
Angles are clockwise in window coordinates: 0 is left-to-right and 90 is
top-to-bottom. Both endpoints accept `#RRGGBB` or `#RRGGBBAA`. Remove either
gradient section to restore its solid `theme.colors.accent` or `border` color.
Gradients share the existing rounded-border shader and add no animation or
extra render pass. Configuration changes reload live.
Overview has a horizontally scrollable workspace strip with live window
thumbnails and an accent outline on the workspace shown by each output.
Click a workspace to change the window grid without leaving overview; click
@@ -214,7 +290,8 @@ no image reload or full-screen shell buffers. The launched shell receives
`FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces.
When no valid image path is configured, the shell's existing fallback remains.
Keyboard and direct-session touchpad settings are applied at startup:
Keyboard settings reload live. Direct-session touchpad settings apply on device
reconnect or session restart:
```toml
[input]
@@ -267,7 +344,8 @@ Unspecified connected outputs remain enabled with their preferred mode, scale
`1.0`, normal transform, and automatic horizontal placement. Supported
transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`,
and their rotated flipped variants. Output configuration is applied at startup
and on hotplug; live configuration reload remains deferred.
and on hotplug. Reloaded output profiles take effect on reconnect or session
restart; they do not change an already-connected output's mode.
On DRM, closing the laptop lid disables internal panels when a usable external
output is available. With no usable external output, the panel stays enabled;
@@ -308,6 +386,10 @@ matching rules are applied in declaration order; later rules override only the
fields they specify. Width or height implies floating placement when `floating`
is omitted:
With `floating = true` and no explicit dimensions, the application chooses its
initial size and Ferese centers that size on its workspace's output. Small
dialogs are not forced into a workspace-sized container.
```toml
[[window_rules]]
app_id = "org.example.Editor"
+1
View File
@@ -15,6 +15,7 @@ ferese-ipc = { path = "../crates/ferese-ipc" }
ferese-layout = { path = "../crates/ferese-layout" }
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
libc.workspace = true
notify = "8.2.0"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
smithay.workspace = true
serde.workspace = true
+27 -1
View File
@@ -134,6 +134,7 @@ struct DirectOutput {
damage_tracker: OutputDamageTracker,
render_metrics: RenderMetrics,
frame_pending: bool,
lock_frame_pending: bool,
}
struct OutputSelection {
@@ -369,13 +370,17 @@ fn open_primary_device(
.insert_source(notifier, move |event, metadata, state| match event {
DrmEvent::VBlank(crtc) => {
if let Some(metadata) = metadata {
let mut retired = false;
let feedback = state
.direct_backend
.as_mut()
.and_then(|backend| backend.devices.get_mut(&node))
.and_then(|device| device.outputs.get_mut(&crtc))
.and_then(|output| match output.surface.frame_submitted() {
Ok(feedback) => feedback,
Ok(feedback) => {
retired = true;
feedback
}
Err(error) => {
tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame");
None
@@ -397,6 +402,21 @@ fn open_primary_device(
.and_then(|backend| backend.devices.get(&node))
.and_then(|device| device.outputs.get(&crtc))
.is_some_and(|output| state.is_focused_output(&output.output));
let lock_output = state
.direct_backend
.as_mut()
.and_then(|backend| backend.devices.get_mut(&node))
.and_then(|device| device.outputs.get_mut(&crtc))
.and_then(|output| {
if retired && std::mem::take(&mut output.lock_frame_pending) {
Some(output.output.clone())
} else {
None
}
});
if let Some(output) = lock_output {
state.lock_frame_presented(&output);
}
if let Some(output) = state
.direct_backend
.as_mut()
@@ -632,6 +652,7 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
effects,
);
output.frame_pending = true;
output.lock_frame_pending = state.session_lock.active;
Ok(true)
})();
@@ -852,6 +873,10 @@ fn refresh_duration(state: &Ferese, node: DrmNode, crtc: crtc::Handle) -> Durati
}
fn send_frame_callbacks(state: &mut Ferese, output: &Output) {
if state.session_lock.active {
state.lock_frame_callbacks(output);
return;
}
state.space.elements().for_each(|window| {
window.send_frame(
output,
@@ -1099,6 +1124,7 @@ fn create_direct_output(
damage_tracker,
render_metrics,
frame_pending: false,
lock_frame_pending: false,
})
}
+160 -1
View File
@@ -16,6 +16,8 @@ use crate::window_rules::{self, WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
pub(crate) autostart: Vec<DaemonConfig>,
#[serde(default)]
animations: AnimationsConfig,
#[serde(default)]
@@ -36,6 +38,26 @@ pub struct Config {
scrolling: ScrollingConfig,
#[serde(default)]
output_profiles: Vec<OutputProfileConfig>,
#[serde(default, rename = "status")]
_status: ShellStatusConfig,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct DaemonConfig {
pub command: Vec<String>,
#[serde(default = "default_true")]
pub restart: bool,
#[serde(default)]
pub nested: bool,
}
// Validate shell-only fields too, before publishing an accepted source.
#[derive(Debug, Default, Deserialize)]
#[allow(dead_code)]
struct ShellStatusConfig {
battery_percentage: Option<bool>,
low_battery_threshold: Option<u8>,
settings_command: Option<Vec<String>>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -105,12 +127,62 @@ struct OutputConfig {
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RgbaColor(pub [f32; 4]);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BorderGradient {
pub from: RgbaColor,
pub to: RgbaColor,
pub angle: f64,
}
#[derive(Debug, Default, Deserialize)]
struct BorderPaintConfig {
gradient: Option<BorderGradientConfig>,
}
#[derive(Debug, Deserialize)]
struct BorderGradientConfig {
from: String,
to: String,
#[serde(default)]
angle: f64,
}
impl BorderPaintConfig {
fn settings(&self, name: &str) -> Result<Option<BorderGradient>, ConfigError> {
let (from_name, to_name, angle_name) = if name == "focus_ring" {
(
"focus_ring.gradient.from",
"focus_ring.gradient.to",
"focus_ring.gradient.angle",
)
} else {
(
"border.gradient.from",
"border.gradient.to",
"border.gradient.angle",
)
};
self.gradient
.as_ref()
.map(|gradient| {
Ok(BorderGradient {
from: parse_color(&gradient.from, from_name)?,
to: parse_color(&gradient.to, to_name)?,
angle: finite_theme_value(gradient.angle, angle_name)?.rem_euclid(360.0),
})
})
.transpose()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThemeSettings {
pub border_width: f64,
pub focus_ring_width: f64,
pub border_color: RgbaColor,
pub accent_color: RgbaColor,
pub border_gradient: Option<BorderGradient>,
pub focus_ring_gradient: Option<BorderGradient>,
pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor,
pub panel_opacity: f64,
@@ -167,6 +239,10 @@ impl Default for InactiveDimConfig {
#[derive(Debug, Default, Deserialize)]
struct ThemeConfig {
#[serde(default)]
border: BorderPaintConfig,
#[serde(default)]
focus_ring: BorderPaintConfig,
#[serde(default)]
typography: OverviewTypographyConfig,
#[serde(default)]
@@ -184,6 +260,46 @@ struct ThemeConfig {
}
impl Config {
pub(crate) fn parse_source(source: &str) -> Result<Self, ConfigError> {
toml::from_str(source).map_err(|source| ConfigError::Parse {
path: config_path().unwrap_or_default(),
source,
})
}
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
for daemon in &self.autostart {
if daemon
.command
.first()
.is_none_or(|program| program.trim().is_empty())
{
return Err(ConfigError::InvalidBinding(
"autostart command must contain a program".into(),
));
}
}
let input_settings = self.input_settings()?;
let bindings = self.bindings(&input_settings)?;
Ok(crate::RuntimeConfig {
layout_mode: self.layout_mode(),
gap_config: self.gap_config()?,
input_settings,
bindings,
window_rules: self.window_rules()?,
theme_settings: self.theme_settings()?,
default_column_width: self.default_column_width()?,
scrolling_focus_strategy: self.scrolling_focus_strategy(),
column_width_presets: self.width_presets()?,
animations_enabled: self.animations_enabled(),
animation_speed: self.animation_speed()?,
spring_config: self.spring_config()?,
viewport_spring_config: self.viewport_spring_config()?,
output_profiles: self.output_profiles()?,
wallpaper: self.wallpaper_settings(),
overview_font_family: self.overview_font_family(),
})
}
pub(crate) fn overview_font_family(&self) -> String {
self.theme
.typography
@@ -905,6 +1021,8 @@ impl Config {
focus_ring_width,
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
border_gradient: self.theme.border.settings("border")?,
focus_ring_gradient: self.theme.focus_ring.settings("focus_ring")?,
shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
surface_base_color: parse_color(
&self.theme.colors.surface_base,
@@ -1576,7 +1694,7 @@ fn parse_column_width(
}
}
fn config_path() -> Option<PathBuf> {
pub(crate) fn config_path() -> Option<PathBuf> {
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(directory).join("ferese/config.toml"));
}
@@ -1724,6 +1842,8 @@ mod tests {
focus_ring_width: 3.0,
border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]),
accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]),
border_gradient: None,
focus_ring_gradient: None,
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
panel_opacity: 0.78,
@@ -1757,6 +1877,45 @@ mod tests {
assert!(toml::from_str::<Config>("[theme.material]\nstyle = \"glass\"").is_err());
}
#[test]
fn border_gradients_are_optional_and_independent() {
let defaults = parse("").theme_settings().unwrap();
assert_eq!(defaults.border_gradient, None);
assert_eq!(defaults.focus_ring_gradient, None);
let configured =
parse("[theme.focus_ring.gradient]\nfrom = '#e5c890'\nto = '#b98d5880'\nangle = -45")
.theme_settings()
.unwrap();
let gradient = configured.focus_ring_gradient.unwrap();
assert_eq!(configured.border_gradient, None);
assert_eq!(gradient.angle, 315.0);
assert_eq!(gradient.to.0[3], 128.0 / 255.0);
let border = parse("[theme.border.gradient]\nfrom = '#112233'\nto = '#445566'")
.theme_settings()
.unwrap();
assert_eq!(border.border_gradient.unwrap().angle, 0.0);
assert_eq!(border.focus_ring_gradient, None);
assert_eq!(configured.border_color, defaults.border_color);
assert_eq!(configured.accent_color, defaults.accent_color);
}
#[test]
fn border_gradients_reject_invalid_colors_and_nonfinite_angles() {
for settings in [
"from = 'invalid'\nto = '#445566'",
"from = '#112233'\nto = 'invalid'",
"from = '#112233'\nto = '#445566'\nangle = nan",
"from = '#112233'\nto = '#445566'\nangle = inf",
] {
assert!(
parse(&format!("[theme.focus_ring.gradient]\n{settings}"))
.theme_settings()
.is_err()
);
}
assert!(toml::from_str::<Config>("[theme.border.gradient]\nfrom = '#112233'").is_err());
}
#[test]
fn material_color_comes_from_surface_base() {
let theme = parse("[theme.colors]\nsurface_base = \"#000000\"\n[theme.surface.bar]\nbackground = \"#FFFFFF\"")
+97
View File
@@ -0,0 +1,97 @@
//! Session-owned services. Never grant shell/effects privileges to daemons.
use crate::{Ferese, config::DaemonConfig, private_client::ClientCapabilities};
use std::{
process::Child,
time::{Duration, Instant},
};
struct Service {
config: DaemonConfig,
child: Option<Child>,
next_start: Instant,
finished: bool,
}
pub(crate) struct Runner(Vec<Service>);
impl Runner {
pub fn new(configs: &[DaemonConfig], nested: bool) -> Self {
Self(
configs
.iter()
.filter(|config| !nested || config.nested)
.map(|config| Service {
config: config.clone(),
child: None,
next_start: Instant::now(),
finished: false,
})
.collect(),
)
}
pub fn tick(&mut self, state: &mut Ferese) {
let now = Instant::now();
for service in &mut self.0 {
if let Some(child) = &mut service.child {
match child.try_wait() {
Ok(Some(status)) => {
tracing::info!(command = ?service.config.command, %status, "session daemon exited");
service.child = None;
service.finished = !service.config.restart;
service.next_start = now + Duration::from_secs(5);
}
Ok(None) => continue,
Err(error) => {
tracing::warn!(%error, "cannot reap session daemon");
continue;
}
}
}
if !service.finished && now >= service.next_start {
service.child = crate::spawn_client(
state,
service.config.command.iter().map(Into::into).collect(),
ClientCapabilities::default(),
);
service.next_start = now + Duration::from_secs(5);
if service.child.is_none() && !service.config.restart {
service.finished = true;
}
}
}
}
pub fn stop(&mut self) {
for service in &mut self.0 {
if let Some(mut child) = service.child.take() {
crate::terminate_child(&mut child);
}
service.finished = true;
}
}
}
impl Drop for Runner {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn previews_do_not_duplicate_session_daemons() {
let configs = vec![DaemonConfig {
command: vec!["awari".into()],
restart: true,
nested: false,
}];
assert!(Runner::new(&configs, true).0.is_empty());
let mut direct = Runner::new(&configs, false);
assert_eq!(direct.0.len(), 1);
direct.stop();
assert!(direct.0[0].finished);
}
}
+32
View File
@@ -54,11 +54,43 @@ impl DimAnimation {
self.current = self.start + (target - self.start) * eased;
self.current != target
}
/// The target changed at this tick, not at the beginning of a potentially
/// long idle interval. Do not spend that old elapsed time on a new fade.
pub fn advance_visual(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
self.advance(
target,
if self.target != target {
Duration::ZERO
} else {
delta
},
duration_ms,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visual_focus_and_dimming_start_together_after_idle_and_reverse() {
let mut focus = DimAnimation::new(0.0);
let mut dim = DimAnimation::new(0.15);
assert!(focus.advance_visual(1.0, Duration::from_secs(30), 150.0));
assert!(dim.advance_visual(0.0, Duration::from_secs(30), 150.0));
assert_eq!(focus.current, 0.0);
assert_eq!(dim.current, 0.15);
focus.advance_visual(1.0, Duration::from_millis(75), 150.0);
dim.advance_visual(0.0, Duration::from_millis(75), 150.0);
assert!((focus.current + dim.current / 0.15 - 1.0).abs() < 1e-9);
let before = focus.current;
focus.advance_visual(0.0, Duration::from_millis(16), 150.0);
assert_eq!(focus.current, before);
assert!(!focus.advance_visual(0.0, Duration::from_millis(150), 150.0));
assert!(!focus.advance_visual(1.0, Duration::from_secs(30), 0.0));
assert_eq!(focus.current, 1.0);
}
#[test]
fn only_unfocused_windows_dim_and_overview_disables_it() {
+74 -3
View File
@@ -65,6 +65,9 @@ struct SurfaceEffectsState {
generation: AtomicU64,
role: Mutex<Option<SemanticRole>>,
regions: Mutex<Option<Vec<[i32; 5]>>>,
opacity: Mutex<u32>,
presentation_supported: AtomicBool,
dismissing: AtomicBool,
}
impl SurfaceEffectsState {
@@ -74,6 +77,9 @@ impl SurfaceEffectsState {
generation: AtomicU64::new(0),
role: Mutex::new(None),
regions: Mutex::new(None),
opacity: Mutex::new(1000),
presentation_supported: AtomicBool::new(false),
dismissing: AtomicBool::new(false),
}
}
@@ -103,7 +109,7 @@ impl SurfaceEffectsUserData {
}
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseEffectsManagerV1, _>(2, ());
display.create_global::<Ferese, FereseEffectsManagerV1, _>(3, ());
}
pub(crate) fn surface_regions(surface: &WlSurface) -> Option<Vec<[i32; 5]>> {
@@ -197,7 +203,15 @@ impl Dispatch<FereseEffectsManagerV1, ()> for Ferese {
"wl_surface already has a Ferese effects object",
);
} else {
data_init.init(id, SurfaceEffectsUserData::new(surface));
let effects = data_init.init(id, SurfaceEffectsUserData::new(surface.clone()));
with_states(&surface, |states| {
states
.data_map
.get::<SurfaceEffectsState>()
.unwrap()
.presentation_supported
.store(effects.version() >= 3, Ordering::Release)
});
}
}
ferese_effects_manager_v1::Request::Destroy => {}
@@ -221,6 +235,27 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
};
match request {
ferese_surface_effects_v1::Request::SetOpacity { opacity } => {
let changed = with_states(&surface, |states| {
let Some(effects) = states.data_map.get::<SurfaceEffectsState>() else {
return false;
};
if effects.dismissing.load(Ordering::Acquire) {
return false;
}
let mut current = effects.opacity.lock().unwrap();
let opacity = opacity.min(1000);
if *current == opacity {
return false;
}
*current = opacity;
effects.generation.fetch_add(1, Ordering::Release);
true
});
if changed {
crate::backends::direct::render_all(state);
}
}
ferese_surface_effects_v1::Request::SetRole { role } => {
let role = match role {
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
@@ -301,11 +336,47 @@ fn detach(surface: &WlSurface) -> bool {
return false;
};
let role_changed = effects.set_role(None);
effects.dismissing.store(false, Ordering::Release);
let mut opacity = effects.opacity.lock().unwrap();
let opacity_changed = *opacity != 1000;
*opacity = 1000;
let was_attached = effects.attached.swap(false, Ordering::AcqRel);
role_changed || was_attached
role_changed || was_attached || opacity_changed
})
}
pub(crate) fn surface_opacity(surface: &WlSurface) -> f32 {
with_states(surface, |states| {
states
.data_map
.get::<SurfaceEffectsState>()
.map_or(1.0, |effects| {
*effects.opacity.lock().unwrap() as f32 / 1000.0
})
})
}
pub(crate) fn begin_surface_dismiss(surface: &WlSurface) -> bool {
with_states(surface, |states| {
states
.data_map
.get::<SurfaceEffectsState>()
.is_some_and(|effects| {
effects.presentation_supported.load(Ordering::Acquire)
&& effects.attached.load(Ordering::Acquire)
&& !effects.dismissing.swap(true, Ordering::AcqRel)
})
})
}
pub(crate) fn fade_dismissed_surface(surface: &WlSurface, opacity: f64) {
with_states(surface, |states| {
if let Some(effects) = states.data_map.get::<SurfaceEffectsState>() {
*effects.opacity.lock().unwrap() = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
effects.generation.fetch_add(1, Ordering::Release);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
+3
View File
@@ -27,6 +27,9 @@ impl XdgActivationHandler for Ferese {
data: XdgActivationTokenData,
surface: WlSurface,
) {
if self.session_lock.active {
return;
}
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
self.xdg_activation_state.remove_token(&token);
if !valid {
+6
View File
@@ -90,6 +90,12 @@ impl CompositorHandler for Ferese {
}
fn destroyed(&mut self, surface: &WlSurface) {
if self.session_lock.active {
self.session_lock
.surfaces
.retain(|_, lock| lock.wl_surface() != surface);
self.focus_lock_surface();
}
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
crate::backends::direct::render_all(self);
if self.idle_inhibitors.remove(surface).is_some() {
+2 -1
View File
@@ -107,7 +107,8 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
}
let policy = layer.cached_state();
if policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
if !state.session_lock.active
&& policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
&& matches!(policy.layer, Layer::Top | Layer::Overlay)
{
state
+4
View File
@@ -137,6 +137,10 @@ impl Ferese {
with_damage: bool,
) {
let mut used = data.used.lock().unwrap();
if self.session_lock.active {
frame.failed();
return;
}
if *used {
frame.post_error(
zwlr_screencopy_frame_v1::Error::AlreadyUsed,
+9
View File
@@ -115,6 +115,9 @@ impl XdgShellHandler for Ferese {
}
fn move_request(&mut self, surface: ToplevelSurface, seat: wl_seat::WlSeat, serial: Serial) {
if self.session_lock.active {
return;
}
let Some(seat) = Seat::from_resource(&seat) else {
return;
};
@@ -163,6 +166,9 @@ impl XdgShellHandler for Ferese {
serial: Serial,
edges: smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge,
) {
if self.session_lock.active {
return;
}
let Some(seat) = Seat::from_resource(&seat) else {
return;
};
@@ -247,6 +253,9 @@ impl XdgShellHandler for Ferese {
}
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) {
if self.session_lock.active {
return;
}
let Some(seat) = Seat::from_resource(&seat) else {
return;
};
+57
View File
@@ -1,3 +1,4 @@
use smithay::reexports::wayland_server::Resource;
use std::process::Command;
use smithay::{
@@ -26,6 +27,21 @@ impl Ferese {
let seat = self.seat.clone();
self.idle_notifier_state.notify_activity(&seat);
if self.session_lock.active {
// An already-bound IME or drag client may install a grab after the
// lock request. Never let that grab receive subsequent lock input.
if let Some(keyboard) = seat.get_keyboard() {
keyboard.unset_grab(self);
}
if let Some(pointer) = seat.get_pointer() {
pointer.unset_grab(self, SERIAL_COUNTER.next_serial(), 0);
}
if let Some(touch) = seat.get_touch() {
touch.unset_grab(self);
}
self.focus_lock_surface();
}
match event {
InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => {
crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On);
@@ -42,6 +58,9 @@ impl Ferese {
SERIAL_COUNTER.next_serial(),
Event::time(&event) as u32,
|data, modifiers, keysym| {
if data.session_lock.active {
return FilterResult::Forward;
}
if state == KeyState::Released && data.intercepted_keys.remove(&keycode) {
return FilterResult::Intercept(());
}
@@ -178,6 +197,30 @@ impl Ferese {
let pointer = self.seat.get_pointer().expect("seat has a pointer");
let serial = SERIAL_COUNTER.next_serial();
// Native popup_done destroys Iced's window immediately. For
// effects-capable shell popups, release input now but defer
// popup_done until the compositor's whole-surface fade ends.
if event.state() == ButtonState::Pressed
&& pointer.is_grabbed()
&& let Some(surface) = self.seat.get_keyboard().and_then(|k| k.current_focus())
&& let Some(popup) = self.popups.find_popup(&surface)
&& !self
.surface_under(pointer.current_location())
.is_some_and(|(target, _)| surface.id().same_client_as(&target.id()))
&& let Ok(root) = smithay::desktop::find_popup_root_surface(&popup)
&& crate::effects::begin_surface_dismiss(&surface)
{
let opacity = crate::effects::surface_opacity(&surface);
self.dismissing_popups.push((
root,
popup,
crate::dimming::DimAnimation::new(f64::from(opacity)),
));
pointer.unset_grab(self, serial, event.time() as u32);
self.focus_window_at(pointer.current_location(), serial);
crate::backends::direct::render_all(self);
}
if self.overview.is_active() {
let position = pointer.current_location();
if self.layer_under(position).is_some() {
@@ -345,6 +388,11 @@ impl Ferese {
}
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial) {
if self.session_lock.active {
self.focus_output_at(position);
self.focus_lock_surface();
return;
}
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
self.focus_output_at(position);
@@ -390,6 +438,9 @@ impl Ferese {
pointer: &PointerHandle<Self>,
position: Point<f64, Logical>,
) {
if self.session_lock.active {
return;
}
if self.overview.is_active()
|| !self.input_settings.focus_follows_mouse
|| pointer.is_grabbed()
@@ -436,6 +487,9 @@ impl Ferese {
pointer: &PointerHandle<Self>,
requested: Point<f64, Logical>,
) -> Point<f64, Logical> {
if self.session_lock.active {
return requested;
}
let current = pointer.current_location();
let Some(focused_surface) = pointer.current_focus() else {
return requested;
@@ -473,6 +527,9 @@ impl Ferese {
}
pub fn activate_focused_pointer_constraint(&self, pointer: &PointerHandle<Self>) {
if self.session_lock.active {
return;
}
let position = pointer.current_location();
let Some((surface, origin)) = self.surface_under(position) else {
return;
+10
View File
@@ -209,6 +209,13 @@ fn serve_connection(
impl Ferese {
fn handle_ipc_request(&mut self, request: Request) -> Response {
if self.session_lock.active {
return Response::error(
request.id,
"session_locked",
"IPC unavailable while session is locked",
);
}
if let Err(error) = validate_request(&request) {
return Response::error(request.id, error.code, error.message);
}
@@ -222,6 +229,9 @@ impl Ferese {
fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> {
match command {
"exit" => {} // The IPC worker stops the loop after writing the response.
"reload-config" => self
.reload_config()
.map_err(|e| CommandError::new("invalid_config", e))?,
"focus" => self.focus_direction(direction_arg(args)?),
"move" => self.move_direction(direction_arg(args)?),
"resize" => self.resize_direction(direction_arg(args)?),
+56 -26
View File
@@ -3,6 +3,7 @@
mod backends;
mod config;
mod cursor;
mod daemon;
mod dimming;
mod effects;
mod grabs;
@@ -13,7 +14,9 @@ mod metrics;
mod overview;
mod presentation;
mod private_client;
mod reload;
mod resize_transaction;
mod session_lock;
mod shell_control;
mod stacking;
mod state;
@@ -42,11 +45,11 @@ fn main() -> Result<(), Box<dyn Error>> {
let launch = LaunchConfig::from_environment()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let config = Config::load()?;
let input_settings = config.input_settings()?;
let bindings = config.bindings(&input_settings)?;
let window_rules = config.window_rules()?;
let output_profiles = config.output_profiles()?;
let initial_source = config::config_path().and_then(|path| std::fs::read_to_string(path).ok());
let config = match initial_source.as_deref() {
Some(source) => Config::parse_source(source)?,
None => Config::load()?,
};
let mut event_loop = EventLoop::try_new()?;
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
event_loop
@@ -56,29 +59,55 @@ fn main() -> Result<(), Box<dyn Error>> {
state.loop_signal.stop();
})?;
let display = Display::new()?;
let runtime = RuntimeConfig {
layout_mode: config.layout_mode(),
gap_config: config.gap_config()?,
input_settings,
bindings,
window_rules,
theme_settings: config.theme_settings()?,
default_column_width: config.default_column_width()?,
scrolling_focus_strategy: config.scrolling_focus_strategy(),
column_width_presets: config.width_presets()?,
animations_enabled: config.animations_enabled(),
animation_speed: config.animation_speed()?,
spring_config: config.spring_config()?,
viewport_spring_config: config.viewport_spring_config()?,
output_profiles,
wallpaper: config.wallpaper_settings(),
overview_font_family: config.overview_font_family(),
};
let runtime = config.runtime_config()?;
let mut state = Ferese::new(&mut event_loop, display, runtime)?;
state.config_source = initial_source.filter(|source| source.len() <= 60 * 1024);
backends::init(launch.backend, &mut event_loop, &mut state)?;
let mut monitor =
config::config_path().and_then(|path| match reload::ConfigMonitor::new(path) {
Ok(monitor) => Some(monitor),
Err(error) => {
warn!(%error, "automatic config watching unavailable; use feresectl reload-config");
None
}
});
use calloop::timer::{TimeoutAction, Timer};
event_loop.handle().insert_source(
Timer::from_duration(Duration::from_millis(150)),
move |_, _, state: &mut Ferese| {
// Decoders finish on a worker even when an idle DRM output has no
// pending frame. Wake rendering once, never continuously for wallpaper.
if state.wallpaper.poll() {
state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
backends::direct::render_all(state);
}
if let Some(result) = monitor
.as_mut()
.and_then(|monitor| monitor.poll(std::time::Instant::now()))
{
if let Err(error) = result.and_then(|source| state.reload_config_source(source)) {
warn!(%error, "config reload rejected; retaining last working config");
}
}
TimeoutAction::ToDuration(Duration::from_millis(150))
},
)?;
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 runner = std::rc::Rc::new(std::cell::RefCell::new(daemon::Runner::new(
&config.autostart,
launch.backend == backends::BackendKind::Nested,
)));
runner.borrow_mut().tick(&mut state);
let services = runner.clone();
event_loop.handle().insert_source(
Timer::from_duration(Duration::from_secs(1)),
move |_, _, state: &mut Ferese| {
services.borrow_mut().tick(state);
TimeoutAction::ToDuration(Duration::from_secs(1))
},
)?;
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.
@@ -93,6 +122,7 @@ fn main() -> Result<(), Box<dyn Error>> {
}
});
runner.borrow_mut().stop();
if let Some(child) = &mut child {
terminate_child(child);
}
@@ -122,9 +152,9 @@ fn spawn_client(
// Wallpaper decoding and drivers may already have worker threads. Set
// the child's display explicitly instead of mutating the process env.
command.env("WAYLAND_DISPLAY", &state.socket_name);
if state.wallpaper.owns_background() {
command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
}
command.env_remove("WAYLAND_SOCKET");
command.env_remove("FERESE_SHELL_CONTROL_SOCKET");
command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
let private_connection = if capabilities.is_empty() {
None
} else {
+8
View File
@@ -103,6 +103,14 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 {
}
impl OverviewState {
pub(crate) fn set_font_family(&mut self, family: String) {
if self.font_family != family {
self.font_family = family;
self.font_loaded = false;
self.font = None;
self.labels.clear();
}
}
pub(crate) fn with_font_family(font_family: String) -> Self {
Self {
font_family,
+217
View File
@@ -0,0 +1,217 @@
//! Filesystem events only: idle timer ticks never read/stat the config.
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use std::{
io::Read,
path::PathBuf,
sync::mpsc,
time::{Duration, Instant},
};
fn read_source(path: &std::path::Path) -> Result<String, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut source = String::new();
file.take(60 * 1024 + 1)
.read_to_string(&mut source)
.map_err(|e| e.to_string())?;
if source.len() > 60 * 1024 {
return Err("configuration exceeds the 60 KiB live-reload limit".into());
}
Ok(source)
}
pub(crate) struct ConfigMonitor {
_watcher: RecommendedWatcher,
events: mpsc::Receiver<()>,
path: PathBuf,
dirty: Option<Instant>,
observed: Option<String>,
}
impl ConfigMonitor {
pub(crate) fn new(path: PathBuf) -> notify::Result<Self> {
let (sender, events) = mpsc::sync_channel(1);
let target = path.clone();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event
&& !event.kind.is_access()
&& event
.paths
.iter()
.any(|p| p == &target || target.starts_with(p))
{
let _ = sender.try_send(());
}
})?;
// Watch the directory, not the inode: atomic editor saves replace it.
let mut directory = path.parent().unwrap_or(std::path::Path::new("."));
while !directory.is_dir() {
directory = directory.parent().unwrap_or(std::path::Path::new("."));
}
watcher.watch(
directory,
if Some(directory) == path.parent() {
RecursiveMode::NonRecursive
} else {
RecursiveMode::Recursive
},
)?;
let initial_dirty = path.is_file().then(Instant::now);
Ok(Self {
_watcher: watcher,
events,
path,
dirty: initial_dirty,
observed: None,
})
}
pub(crate) fn poll(&mut self, now: Instant) -> Option<Result<String, String>> {
if self.events.try_iter().next().is_some() {
self.dirty = Some(now);
}
if self
.dirty
.is_none_or(|since| now.saturating_duration_since(since) < Duration::from_millis(120))
{
return None;
}
self.dirty = None;
let source = match read_source(&self.path) {
Ok(source) => source,
Err(error) => {
return Some(Err(format!(
"{}: {error}; retaining active config",
self.path.display()
)));
}
};
if self.observed.as_ref() == Some(&source) {
return None;
}
self.observed = Some(source.clone());
Some(Ok(source))
}
}
impl crate::Ferese {
pub(crate) fn reload_config_source(&mut self, source: String) -> Result<(), String> {
if source.len() > 60 * 1024 {
return Err("configuration exceeds the 60 KiB live-reload limit".into());
}
if self.config_source.as_ref() == Some(&source) {
return Ok(());
}
// Validate every runtime field before mutating any scene/input state.
let config = crate::config::Config::parse_source(&source).map_err(|e| e.to_string())?;
let runtime = config.runtime_config().map_err(|e| e.to_string())?;
self.apply_runtime_config(runtime)?;
self.config_source = Some(source.clone());
self.shell_resources.retain(|resource| {
if let Ok(shell) = resource.upgrade() {
if smithay::reexports::wayland_server::Resource::version(&shell) >= 2 {
crate::shell_control::send_shell_config(&shell, &source);
}
true
} else {
false
}
});
tracing::info!("configuration reloaded live");
Ok(())
}
pub(crate) fn reload_config(&mut self) -> Result<(), String> {
let path = crate::config::config_path().ok_or("config directory is unavailable")?;
let source = read_source(&path)?;
self.reload_config_source(source)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"ferese-reload-test-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Directory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn runtime_validation_rejects_bad_edits_before_application() {
for source in [
"[animations]\nspeed = 0",
"[theme.focus_ring.gradient]\nfrom = 'bad'\nto = '#ffffff'",
"[status]\nlow_battery_threshold = 'bad'",
"[layout]\ninner_gap = -1",
] {
assert!(
crate::config::Config::parse_source(source)
.and_then(|config| config.runtime_config())
.is_err()
);
}
assert!(
crate::config::Config::parse_source("[animations]\nspeed = 0.75")
.unwrap()
.runtime_config()
.is_ok()
);
}
#[test]
fn directory_watch_detects_atomic_save_and_does_not_read_during_idle() {
let directory = Directory::new();
let path = directory.0.join("config.toml");
std::fs::write(&path, "[animations]\nspeed = 1").unwrap();
let mut monitor = ConfigMonitor::new(path.clone()).unwrap();
monitor.dirty = Some(Instant::now() - Duration::from_secs(1));
assert!(monitor.poll(Instant::now()).unwrap().is_ok());
// With no event, even a deliberately missing path is never read.
let original_path = monitor.path.clone();
monitor.path = directory.0.join("missing");
assert!(monitor.poll(Instant::now()).is_none());
monitor.path = original_path;
let temporary = directory.0.join("config.toml.new");
std::fs::write(&temporary, "[animations]\nspeed = 0.75").unwrap();
std::fs::rename(&temporary, &path).unwrap();
let deadline = Instant::now() + Duration::from_secs(3);
loop {
if let Some(result) = monitor.poll(Instant::now()) {
assert_eq!(result.unwrap(), "[animations]\nspeed = 0.75");
break;
}
assert!(
Instant::now() < deadline,
"atomic save notification never settled"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn source_size_limit_is_bounded_and_debounce_coalesces_edits() {
let directory = Directory::new();
let path = directory.0.join("config.toml");
std::fs::write(&path, "x".repeat(60 * 1024 + 1)).unwrap();
assert!(read_source(&path).is_err());
std::fs::write(&path, "[animations]\nspeed = 1").unwrap();
let mut monitor = ConfigMonitor::new(path).unwrap();
let now = Instant::now();
monitor.dirty = Some(now);
assert!(monitor.poll(now + Duration::from_millis(119)).is_none());
assert!(
monitor
.poll(now + Duration::from_millis(121))
.unwrap()
.is_ok()
);
}
}
+211
View File
@@ -0,0 +1,211 @@
use crate::Ferese;
use smithay::{
output::Output,
reexports::{
wayland_protocols::ext::session_lock::v1::server::{
ext_session_lock_manager_v1::ExtSessionLockManagerV1,
ext_session_lock_surface_v1::ExtSessionLockSurfaceV1,
ext_session_lock_v1::{self, ExtSessionLockV1},
},
wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, Resource, protocol::wl_output::WlOutput,
},
},
utils::{Logical, Point, SERIAL_COUNTER},
wayland::session_lock::{
LockSurface, SessionLockHandler, SessionLockManagerState, SessionLockState, SessionLocker,
},
};
use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub(crate) struct Lock {
pub active: bool,
owner: Option<ExtSessionLockV1>,
confirmation: Option<SessionLocker>,
pub surfaces: HashMap<Output, LockSurface>,
pub backgrounds: HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>,
presented: HashSet<Output>,
confirmed: bool,
}
impl Ferese {
pub(crate) fn lock_surface_under(
&self,
position: Point<f64, Logical>,
) -> Option<(
smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
Point<f64, Logical>,
)> {
let output = self.space.output_under(position).next()?;
let surface = self.session_lock.surfaces.get(output)?;
surface.alive().then(|| {
(
surface.wl_surface().clone(),
self.space.output_geometry(output).unwrap().loc.to_f64(),
)
})
}
pub(crate) fn focus_lock_surface(&mut self) {
let surface = self
.focused_output()
.and_then(|output| self.session_lock.surfaces.get(output))
.or_else(|| {
self.session_lock
.surfaces
.values()
.find(|surface| surface.alive())
})
.filter(|surface| surface.alive())
.map(|surface| surface.wl_surface().clone());
self.seat
.get_keyboard()
.unwrap()
.set_focus(self, surface, SERIAL_COUNTER.next_serial());
}
pub(crate) fn lock_frame_presented(&mut self, output: &Output) {
if !self.session_lock.active {
return;
}
self.session_lock.presented.insert(output.clone());
if self
.space
.outputs()
.all(|output| self.session_lock.presented.contains(output))
{
if let Some(confirmation) = self.session_lock.confirmation.take() {
if confirmation.ext_session_lock().is_alive() {
confirmation.lock();
self.session_lock.confirmed = true;
tracing::info!("session lock confirmed after safe output frames");
}
}
}
}
pub(crate) fn configure_lock_surfaces(&mut self) {
for (output, surface) in &self.session_lock.surfaces {
if let Some(geometry) = self.space.output_geometry(output) {
surface.with_pending_state(|state| {
state.size = Some((geometry.size.w as u32, geometry.size.h as u32).into())
});
surface.send_configure();
}
}
}
pub(crate) fn lock_frame_callbacks(&self, output: &Output) {
if let Some(surface) = self
.session_lock
.surfaces
.get(output)
.filter(|surface| surface.alive())
{
smithay::desktop::utils::send_frames_surface_tree(
surface.wl_surface(),
output,
self.start_time.elapsed(),
Some(std::time::Duration::ZERO),
|_, _| Some(output.clone()),
);
}
}
}
impl SessionLockHandler for Ferese {
fn lock_state(&mut self) -> &mut SessionLockManagerState {
&mut self.session_lock_state
}
fn lock(&mut self, confirmation: SessionLocker) {
if self.session_lock.active {
return;
}
self.session_lock.active = true;
self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
self.session_lock.confirmation = Some(confirmation);
for capture in self.pending_screencopies.drain(..) {
capture.frame.failed();
}
self.set_overview_active(false);
let serial = SERIAL_COUNTER.next_serial();
if let Some(pointer) = self.seat.get_pointer() {
pointer.unset_grab(self, serial, 0);
pointer.motion(
self,
None,
&smithay::input::pointer::MotionEvent {
location: pointer.current_location(),
serial,
time: 0,
},
);
pointer.frame(self);
}
if let Some(touch) = self.seat.get_touch() {
touch.unset_grab(self);
touch.cancel(self);
}
let keyboard = self.seat.get_keyboard().unwrap();
keyboard.unset_grab(self);
keyboard.set_focus(self, None, serial);
self.intercepted_keys.clear();
crate::backends::direct::render_all(self);
}
fn unlock(&mut self) {
self.session_lock = Lock::default();
self.restore_keyboard_focus();
crate::backends::direct::render_all(self);
tracing::info!("session unlocked by lock owner");
}
fn new_surface(&mut self, surface: LockSurface, output: WlOutput) {
if let Some(output) = Output::from_resource(&output) {
if let Some(geometry) = self.space.output_geometry(&output) {
surface.with_pending_state(|state| {
state.size = Some((geometry.size.w as u32, geometry.size.h as u32).into())
});
self.session_lock.surfaces.insert(output, surface);
self.focus_lock_surface();
}
}
}
}
// Smithay delegates unlock even after posting InvalidUnlock. Check ownership
// and confirmation first so a rejected/unfinished lock cannot unlock the owner.
impl Dispatch<ExtSessionLockV1, SessionLockState> for Ferese {
fn request(
state: &mut Self,
client: &Client,
lock: &ExtSessionLockV1,
request: ext_session_lock_v1::Request,
data: &SessionLockState,
display: &DisplayHandle,
data_init: &mut DataInit<'_, Self>,
) {
let owner = state.session_lock.owner.as_ref() == Some(lock);
if matches!(request, ext_session_lock_v1::Request::UnlockAndDestroy)
&& (!owner || !state.session_lock.confirmed)
{
lock.post_error(
ext_session_lock_v1::Error::InvalidUnlock,
"lock not confirmed or not the active owner",
);
return;
}
if matches!(request, ext_session_lock_v1::Request::GetLockSurface { .. }) && !owner {
lock.post_error(
ext_session_lock_v1::Error::InvalidUnlock,
"not the active lock owner",
);
return;
}
<SessionLockManagerState as Dispatch<ExtSessionLockV1, SessionLockState, Ferese>>::request(
state, client, lock, request, data, display, data_init,
);
}
}
smithay::reexports::wayland_server::delegate_global_dispatch!(Ferese: [ExtSessionLockManagerV1: smithay::wayland::session_lock::SessionLockManagerGlobalData] => SessionLockManagerState);
smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockManagerV1: ()] => SessionLockManagerState);
smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockSurfaceV1: smithay::wayland::session_lock::ExtLockSurfaceUserData] => SessionLockManagerState);
+43 -1
View File
@@ -14,7 +14,28 @@ use smithay::{
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseShellManagerV1, _>(1, ());
display.create_global::<Ferese, FereseShellManagerV1, _>(2, ());
}
pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
let mut remaining = source;
let mut chunks = Vec::new();
while !remaining.is_empty() {
let mut boundary = remaining.len().min(1024);
while !remaining.is_char_boundary(boundary) {
boundary -= 1;
}
chunks.push(&remaining[..boundary]);
remaining = &remaining[boundary..];
}
chunks
}
pub(crate) fn send_shell_config(shell: &FereseShellV1, source: &str) {
shell.config_begin();
for chunk in config_chunks(source) {
shell.config_chunk(chunk.to_owned());
}
shell.config_end();
}
impl GlobalDispatch<FereseShellManagerV1, ()> for Ferese {
@@ -53,6 +74,11 @@ impl Dispatch<FereseShellManagerV1, ()> for Ferese {
let shell = data_init.init(id, ());
shell.capabilities(1);
if shell.version() >= 2
&& let Some(source) = &state.config_source
{
send_shell_config(&shell, source);
}
shell.overview_state(u32::from(state.overview.is_active()));
state.send_shell_snapshot(&shell);
state.shell_resources.push(shell.downgrade());
@@ -73,6 +99,9 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
_display: &DisplayHandle,
_data_init: &mut DataInit<'_, Self>,
) {
if state.session_lock.active {
return;
}
match request {
ferese_shell_v1::Request::ActivateWindow {
window_hi,
@@ -371,6 +400,19 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe
mod tests {
use super::*;
#[test]
fn config_chunks_are_bounded_and_preserve_unicode() {
let source = "abc🌲é".repeat(9000);
let chunks = config_chunks(&source);
assert!(
chunks
.iter()
.all(|chunk| !chunk.is_empty() && chunk.len() <= 1024)
);
assert_eq!(chunks.concat(), source);
assert!(config_chunks("").is_empty());
}
#[test]
fn window_ids_round_trip_through_protocol_words() {
for id in [0, 1, u32::MAX as u64, u64::from(u32::MAX) + 1, u64::MAX] {
+19
View File
@@ -1,5 +1,17 @@
use ferese_layout::WindowId;
pub(crate) fn layer_priority(floating: bool, zooming: bool, fullscreen: bool) -> u8 {
if fullscreen {
3
} else if floating {
2
} else if zooming {
1
} else {
0
}
}
/// Persistent back-to-front order. Geometry updates never change this order.
#[derive(Default)]
pub(crate) struct WindowStack {
@@ -34,6 +46,13 @@ impl WindowStack {
mod tests {
use super::*;
#[test]
fn floating_windows_stay_above_tiled_and_maximized_windows() {
assert!(layer_priority(true, false, false) > layer_priority(false, true, false));
assert!(layer_priority(false, true, false) > layer_priority(false, false, false));
assert!(layer_priority(false, false, true) > layer_priority(true, false, false));
}
#[test]
fn remapping_neighbours_does_not_change_order() {
let mut stack = WindowStack::default();
+215 -10
View File
@@ -107,6 +107,9 @@ impl ClosingAnimation {
}
pub struct Ferese {
pub(crate) session_lock_state: smithay::wayland::session_lock::SessionLockManagerState,
pub(crate) session_lock: crate::session_lock::Lock,
pub(crate) config_source: Option<String>,
pub start_time: Instant,
pub socket_name: OsString,
pub display_handle: DisplayHandle,
@@ -125,10 +128,12 @@ pub struct Ferese {
maximized_windows: HashSet<WindowId>,
maximized_column_widths: HashMap<WindowId, ColumnWidth>,
window_stack: crate::stacking::WindowStack,
natural_floating_pending: HashSet<WindowId>,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dims: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_resize_fills: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dimming: HashMap<WindowId, crate::dimming::DimAnimation>,
pub(crate) window_focus: HashMap<WindowId, crate::dimming::DimAnimation>,
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
@@ -178,6 +183,11 @@ pub struct Ferese {
next_output_id: u64,
last_animation_tick: Instant,
pub popups: PopupManager,
pub(crate) dismissing_popups: Vec<(
WlSurface,
smithay::desktop::PopupKind,
crate::dimming::DimAnimation,
)>,
pub seat: Seat<Self>,
pub alpha_modifier_state: AlphaModifierState,
pub compositor_state: CompositorState,
@@ -227,6 +237,65 @@ pub struct RuntimeConfig {
}
impl Ferese {
pub(crate) fn apply_runtime_config(&mut self, config: RuntimeConfig) -> Result<(), String> {
let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout
|| self.input_settings.xkb_variant != config.input_settings.xkb_variant
|| self.input_settings.xkb_options != config.input_settings.xkb_options;
if let Some(keyboard) = self.seat.get_keyboard() {
if keyboard_changed {
let options = (!config.input_settings.xkb_options.is_empty())
.then(|| config.input_settings.xkb_options.join(","));
keyboard
.set_xkb_config(
self,
XkbConfig {
layout: &config.input_settings.xkb_layout,
variant: &config.input_settings.xkb_variant,
options,
..XkbConfig::default()
},
)
.map_err(|e| format!("keymap reload failed: {e}"))?;
}
keyboard.change_repeat_info(
config.input_settings.repeat_rate,
config.input_settings.repeat_delay_ms,
);
}
self.advance_animations(Instant::now());
if self.input_settings.touchpad != config.input_settings.touchpad {
tracing::warn!(
"touchpad changes apply when the device reconnects or the session restarts"
);
}
if self.output_profiles != config.output_profiles {
tracing::warn!(
"output profile changes apply on output reconnect; current modes are preserved"
);
}
self.workspaces.reconfigure_defaults(
config.layout_mode,
config.default_column_width,
config.scrolling_focus_strategy,
);
self.gap_config = config.gap_config;
self.input_settings = config.input_settings;
self.bindings = config.bindings;
self.window_rules = config.window_rules;
self.theme_settings = config.theme_settings;
self.column_width_presets = config.column_width_presets;
self.animations_enabled = config.animations_enabled;
self.animation_speed = config.animation_speed;
self.spring_config = config.spring_config;
self.viewport_spring_config = config.viewport_spring_config;
self.output_profiles = config.output_profiles;
self.overview.set_font_family(config.overview_font_family);
self.wallpaper.reload(config.wallpaper);
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
self.relayout();
crate::backends::direct::render_all(self);
Ok(())
}
pub fn register_output(&mut self, output: &Output, identity: String) {
let Some(geometry) = self.space.output_geometry(output) else {
tracing::error!(output = %output.name(), "cannot register an unmapped output");
@@ -286,6 +355,8 @@ impl Ferese {
}
});
self.space.unmap_output(output);
self.session_lock.surfaces.remove(output);
self.session_lock.backgrounds.remove(output);
match self.output_workspaces.disconnect(output_id) {
Ok(Some(target)) => {
@@ -406,6 +477,10 @@ impl Ferese {
let fractional_scale_state = FractionalScaleManagerState::new::<Self>(&display_handle);
let idle_inhibit_state = IdleInhibitManagerState::new::<Self>(&display_handle);
let idle_notifier_state = IdleNotifierState::new(&display_handle, event_loop.handle());
let session_lock_state = smithay::wayland::session_lock::SessionLockManagerState::new::<
Self,
_,
>(&display_handle, |_| true);
let input_method_enabled =
std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1");
let input_method_manager_state =
@@ -456,6 +531,9 @@ impl Ferese {
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
let mut state = Self {
session_lock_state,
session_lock: crate::session_lock::Lock::default(),
config_source: None,
start_time,
socket_name,
display_handle,
@@ -478,10 +556,12 @@ impl Ferese {
maximized_windows: HashSet::new(),
maximized_column_widths: HashMap::new(),
window_stack: crate::stacking::WindowStack::default(),
natural_floating_pending: HashSet::new(),
window_borders: HashMap::new(),
window_dims: HashMap::new(),
window_resize_fills: HashMap::new(),
window_dimming: HashMap::new(),
window_focus: HashMap::new(),
window_shadows: HashMap::new(),
rounded_clip_programs: HashMap::new(),
overview_scrims: HashMap::new(),
@@ -525,6 +605,7 @@ impl Ferese {
next_output_id: 1,
last_animation_tick: start_time,
popups: PopupManager::default(),
dismissing_popups: Vec::new(),
seat,
alpha_modifier_state,
compositor_state,
@@ -588,6 +669,9 @@ impl Ferese {
&self,
position: Point<f64, Logical>,
) -> Option<(WlSurface, Point<f64, Logical>)> {
if self.session_lock.active {
return self.lock_surface_under(position);
}
self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
.map(|(_, surface, origin)| (surface, origin))
.or_else(|| self.window_surface_under(position))
@@ -601,6 +685,9 @@ impl Ferese {
&self,
position: Point<f64, Logical>,
) -> Option<(LayerSurface, WlSurface, Point<f64, Logical>)> {
if self.session_lock.active {
return None;
}
if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) {
return Some(layer);
}
@@ -739,9 +826,18 @@ impl Ferese {
let mut windows = self.space.elements().cloned().collect::<Vec<_>>();
windows.sort_by_key(|window| {
let id = self.window_ids.get(window).copied();
let priority = id
.and_then(|id| self.window_geometry.get(&id))
.is_some_and(|geometry| geometry.is_fullscreen() || geometry.is_zooming());
let geometry = id.and_then(|id| self.window_geometry.get(&id));
let floating = id.is_some_and(|id| {
matches!(
self.workspaces.placement(id),
Some(WindowPlacement::Floating { .. })
)
});
let priority = crate::stacking::layer_priority(
floating,
geometry.is_some_and(|geometry| geometry.is_zooming()),
geometry.is_some_and(|geometry| geometry.is_fullscreen()),
);
(
priority,
id.map_or(usize::MAX, |id| self.window_stack.rank(id)),
@@ -891,6 +987,13 @@ impl Ferese {
Some(WindowPlacement::Floating { .. })
);
let mut rect = centered_floating_rect(bounds);
if should_float && rule.width.is_none() && rule.height.is_none() {
if let Some(size) = client_size(window) {
rect = natural_floating_rect(bounds, size);
} else {
self.natural_floating_pending.insert(id);
}
}
rect.width = rule.width.unwrap_or(rect.width).min(bounds.width);
rect.height = rule.height.unwrap_or(rect.height).min(bounds.height);
rect.x = bounds.x + (bounds.width - rect.width) / 2.0;
@@ -974,9 +1077,11 @@ impl Ferese {
self.maximized_windows.remove(&id);
self.maximized_column_widths.remove(&id);
self.window_stack.remove(id);
self.natural_floating_pending.remove(&id);
self.window_borders.remove(&id);
self.window_dims.remove(&id);
self.window_dimming.remove(&id);
self.window_focus.remove(&id);
self.window_shadows.remove(&id);
self.closing_windows.remove(&id);
self.window_rules_applied.remove(&id);
@@ -994,6 +1099,9 @@ impl Ferese {
}
pub fn relayout(&mut self) {
if self.session_lock.active {
self.configure_lock_surfaces();
}
// Consume idle time before setting new targets; it must not become a
// large first animation step after a keypress on an idle desktop.
self.advance_animations(Instant::now());
@@ -1214,12 +1322,21 @@ impl Ferese {
}
}
let visual = geometry.visual.current;
let natural_pending = self.natural_floating_pending.contains(&id)
&& is_floating
&& !is_fullscreen
&& !is_maximized;
if natural_pending {
requested_size = None;
}
let location = (visual.x.round() as i32, visual.y.round() as i32);
self.space.map_element(window.clone(), location, false);
if let Some(toplevel) = window.toplevel() {
let state_changed = toplevel.with_pending_state(|state| {
if let Some(size) = requested_size {
if natural_pending {
state.size = None;
} else if let Some(size) = requested_size {
state.size = Some((size.width, size.height).into());
}
@@ -1258,7 +1375,7 @@ impl Ferese {
fullscreen_changed || maximized_changed || tiled_changed || decoration_changed
});
if requested_size.is_some() || state_changed {
if natural_pending || requested_size.is_some() || state_changed {
if let Some(serial) = toplevel.send_pending_configure()
&& requested_size.is_some()
&& had_geometry
@@ -1327,6 +1444,19 @@ impl Ferese {
})
.collect::<Vec<_>>();
let mut active_animation = false;
self.dismissing_popups.retain_mut(|(root, popup, motion)| {
if !smithay::utils::IsAlive::alive(popup.wl_surface()) {
return false;
}
let duration = if self.animations_enabled { 140.0 } else { 0.0 };
let active = motion.advance_visual(0.0, delta, duration);
crate::effects::fade_dismissed_surface(popup.wl_surface(), motion.current);
active_animation |= active;
if !active {
let _ = PopupManager::dismiss_popup(root, popup);
}
active
});
let dim_settings = self.theme_settings.inactive_dim;
let duration = if self.animations_enabled {
@@ -1335,6 +1465,22 @@ impl Ferese {
0.0
};
let mut dim_changed = false;
// Include hidden workspace windows: overview can present them too.
for id in self.window_ids.values().copied().collect::<Vec<_>>() {
let selected = if self.overview.is_active() {
self.overview_selected(id)
} else {
self.focused_window == Some(id)
};
let target = if selected { 1.0 } else { 0.0 };
let focus = self
.window_focus
.entry(id)
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
let previous = focus.current;
active_animation |= focus.advance_visual(target, delta, duration);
dim_changed |= previous != focus.current;
}
for (_, id) in &windows {
let target = crate::dimming::target(
dim_settings,
@@ -1347,7 +1493,7 @@ impl Ferese {
.entry(*id)
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
let previous = dim.current;
active_animation |= dim.advance(target, delta, duration);
active_animation |= dim.advance_visual(target, delta, duration);
dim_changed |= previous != dim.current;
}
@@ -1504,6 +1650,7 @@ impl Ferese {
);
}
if let Some(size) = geometry.presentation_size_request(self.start_time.elapsed())
&& !self.natural_floating_pending.contains(&id)
&& let Some(toplevel) = window.toplevel()
{
toplevel.with_pending_state(|state| {
@@ -1625,7 +1772,7 @@ impl Ferese {
}
pub fn record_client_commit(&mut self, window: &Window) {
let Some(id) = self.window_ids.get(window) else {
let Some(&id) = self.window_ids.get(window) else {
return;
};
// current_serial is promoted by Smithay on commit, unlike
@@ -1640,15 +1787,32 @@ impl Ferese {
});
if self
.resize_transactions
.get(id)
.get(&id)
.is_some_and(|transaction| transaction.accepts(committed_serial))
{
self.resize_transactions.remove(id);
self.resize_transactions.remove(&id);
}
let Some(size) = client_size(window) else {
return;
};
let Some(geometry) = self.window_geometry.get_mut(id) else {
if self.natural_floating_pending.remove(&id)
&& matches!(
self.workspaces.placement(id),
Some(WindowPlacement::Floating { .. })
)
&& let Some(bounds) = self.floating_bounds_for_window(id)
{
let rect = natural_floating_rect(bounds, size);
if self.workspaces.set_floating_rect(id, rect).is_ok() {
// This is the first mapped client buffer, not a user resize.
// Do not animate from the temporary placement box.
self.window_geometry
.insert(id, WindowGeometry::new(rect, Some(size)));
self.resize_transactions.remove(&id);
self.relayout();
}
}
let Some(geometry) = self.window_geometry.get_mut(&id) else {
return;
};
@@ -2204,6 +2368,10 @@ impl Ferese {
}
pub(crate) fn restore_keyboard_focus(&mut self) {
if self.session_lock.active {
self.focus_lock_surface();
return;
}
let surface = self.focused_window.and_then(|focused| {
self.window_ids.iter().find_map(|(window, id)| {
(*id == focused)
@@ -2226,6 +2394,18 @@ impl Ferese {
self.output_bounds_for(output)
}
fn floating_bounds_for_window(&self, window: WindowId) -> Option<Rect> {
let output_id = self
.workspaces
.workspace_for_window(window)
.and_then(|workspace| self.output_workspaces.output_for_workspace(workspace));
self.output_ids
.iter()
.find(|(_, id)| Some(**id) == output_id)
.and_then(|(output, _)| self.output_bounds_for(output))
.or_else(|| self.output_bounds())
}
pub(crate) fn output_bounds_for(&self, output: &Output) -> Option<Rect> {
let geometry = self.space.output_geometry(output)?;
let zone = layer_map_for_output(output).non_exclusive_zone();
@@ -2339,6 +2519,17 @@ fn centered_floating_rect(bounds: Rect) -> Rect {
)
}
fn natural_floating_rect(bounds: Rect, size: ClientSize) -> Rect {
let width = f64::from(size.width).clamp(1.0, bounds.width.max(1.0));
let height = f64::from(size.height).clamp(1.0, bounds.height.max(1.0));
Rect::new(
bounds.x + (bounds.width - width) / 2.0,
bounds.y + (bounds.height - height) / 2.0,
width,
height,
)
}
fn smoothstep(progress: f64) -> f64 {
let progress = progress.clamp(0.0, 1.0);
@@ -2394,6 +2585,20 @@ impl ClientData for ClientState {
mod tests {
use super::*;
#[test]
fn floating_dialog_keeps_its_client_size_not_workspace_proportion() {
assert_eq!(
natural_floating_rect(
Rect::new(100.0, 48.0, 1600.0, 900.0),
ClientSize {
width: 400,
height: 200
}
),
Rect::new(700.0, 398.0, 400.0, 200.0)
);
}
#[test]
fn maximization_respects_layer_exclusion_and_outer_gaps() {
assert_eq!(
+86 -2
View File
@@ -17,7 +17,7 @@ use smithay::{
};
use std::{collections::HashMap, path::PathBuf, sync::mpsc};
#[derive(Clone, Debug, Default, Deserialize)]
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub(crate) struct WallpaperConfig {
pub path: Option<PathBuf>,
#[serde(default)]
@@ -36,6 +36,9 @@ struct WallpaperTexture {
id: Id,
}
pub(crate) struct WallpaperState {
config: WallpaperConfig,
pending: Option<WallpaperConfig>,
commit: CommitCounter,
owned: bool,
mode: WallpaperMode,
receiver: Option<mpsc::Receiver<Result<image::RgbaImage, String>>>,
@@ -45,6 +48,7 @@ pub(crate) struct WallpaperState {
impl WallpaperState {
pub fn new(config: WallpaperConfig) -> Self {
let retained = config.clone();
let (sender, receiver) = mpsc::channel();
let owned = config.path.as_ref().is_some_and(|path| path.is_file());
if owned {
@@ -64,6 +68,9 @@ impl WallpaperState {
});
}
Self {
config: retained,
pending: None,
commit: CommitCounter::default(),
owned,
mode: config.mode,
receiver: owned.then_some(receiver),
@@ -71,9 +78,38 @@ impl WallpaperState {
textures: HashMap::new(),
}
}
#[cfg(test)]
pub fn owns_background(&self) -> bool {
self.owned
}
pub fn reload(&mut self, config: WallpaperConfig) {
if self.receiver.is_some() {
self.pending = (self.config != config).then_some(config);
return;
}
if self.config == config {
return;
}
if self.config.path == config.path {
self.mode = config.mode;
self.config = config;
self.commit.increment();
return;
}
if config.path.as_ref().is_some_and(|path| !path.is_file()) {
tracing::warn!("new wallpaper is unavailable; retaining previous image");
return;
}
let mut replacement = Self::new(config);
self.config = replacement.config;
self.mode = replacement.mode;
self.owned = replacement.owned;
self.receiver = replacement.receiver.take();
if self.config.path.is_none() {
self.pixels = None;
self.textures.clear();
}
}
pub fn forget_context(&mut self, context: &ErasedContextId) {
self.textures.remove(context);
}
@@ -84,6 +120,14 @@ impl WallpaperState {
match receiver.try_recv() {
Ok(result) => {
self.receiver = None;
if let Some(pending) = self.pending.take() {
if pending.path != self.config.path {
self.reload(pending);
return true;
}
self.config = pending;
self.mode = self.config.mode;
}
match result {
Ok(pixels) => {
tracing::info!(
@@ -93,6 +137,8 @@ impl WallpaperState {
"decoded compositor wallpaper once"
);
self.pixels = Some(pixels);
self.textures.clear();
self.commit.increment();
}
Err(error) => {
tracing::warn!(%error, "wallpaper unavailable; using output clear color")
@@ -147,7 +193,7 @@ impl WallpaperState {
);
Some(NativeTextureElement {
id: cached.id.clone(),
commit: CommitCounter::default(),
commit: self.commit,
texture: cached.texture.clone(),
geometry,
source,
@@ -204,6 +250,44 @@ fn image_geometry(
mod tests {
use super::*;
#[test]
fn live_reload_coalesces_decoders_and_mode_changes_invalidate_damage() {
let mut state = WallpaperState::new(WallpaperConfig::default());
let (sender, receiver) = mpsc::channel();
state.receiver = Some(receiver);
state.reload(WallpaperConfig {
path: None,
mode: WallpaperMode::Fit,
});
assert!(state.receiver.is_some());
let before = state.commit;
sender.send(Ok(image::RgbaImage::new(2, 2))).unwrap();
assert!(state.poll());
assert_eq!(state.mode, WallpaperMode::Fit);
assert!(state.pixels.is_some());
assert_ne!(state.commit, before);
assert!(state.receiver.is_none());
let before = state.commit;
state.reload(state.config.clone());
assert_eq!(state.commit, before);
}
#[test]
fn reverting_a_pending_reload_keeps_latest_request_and_failed_decode_keeps_pixels() {
let mut state = WallpaperState::new(WallpaperConfig::default());
state.pixels = Some(image::RgbaImage::new(2, 2));
let (sender, receiver) = mpsc::channel();
state.receiver = Some(receiver);
state.reload(WallpaperConfig {
path: None,
mode: WallpaperMode::Fit,
});
state.reload(WallpaperConfig::default());
assert!(state.pending.is_none());
sender.send(Err("bad image".into())).unwrap();
assert!(state.poll());
assert!(state.pixels.is_some());
assert_eq!(state.mode, WallpaperMode::Fill);
}
#[test]
fn fill_crops_and_fit_letterboxes_without_reallocating_image() {
let (geometry, source) = image_geometry(
(3840, 2160).into(),
+266 -23
View File
@@ -85,6 +85,7 @@ render_elements! {
Surface=SurfaceRenderElement,
Memory=MemoryRenderElement,
Solid=SolidColorRenderElement,
LockSurface=WaylandSurfaceRenderElement<GlesRenderer>,
Border=PixelShaderElement,
Blur=BlurRenderElement,
Effect=PhysicalShaderElement,
@@ -226,13 +227,19 @@ void main() {
"#;
const ROUNDED_BORDER_SHADER: &str = r#"
precision mediump float;
precision highp float;
uniform float alpha;
uniform vec4 clip_rect;
uniform float radius;
uniform float border_width;
uniform vec4 border_color;
uniform vec4 border_color_to;
uniform vec4 gradient_line;
uniform vec4 focus_color;
uniform vec4 focus_color_to;
uniform vec4 focus_gradient_line;
uniform float focus_mix;
varying vec2 v_coords;
#if defined(DEBUG_FLAGS)
@@ -257,8 +264,15 @@ void main() {
- inner_radius;
float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance);
float coverage = max(outer_coverage - inner_coverage, 0.0);
vec4 color = vec4(border_color.rgb * border_color.a, border_color.a)
* coverage * alpha;
float progress = clamp(dot(gl_FragCoord.xy - gradient_line.xy, gradient_line.zw), 0.0, 1.0);
// Interpolate premultiplied endpoints: a transparent endpoint must not
// leak its RGB into the visible border or create a dark halo.
vec4 from = vec4(border_color.rgb * border_color.a, border_color.a);
vec4 to = vec4(border_color_to.rgb * border_color_to.a, border_color_to.a);
float focus_progress = clamp(dot(gl_FragCoord.xy - focus_gradient_line.xy, focus_gradient_line.zw), 0.0, 1.0);
vec4 focus_from = vec4(focus_color.rgb * focus_color.a, focus_color.a);
vec4 focus_to = vec4(focus_color_to.rgb * focus_color_to.a, focus_color_to.a);
vec4 color = mix(mix(from, to, progress), mix(focus_from, focus_to, focus_progress), focus_mix) * coverage * alpha;
#if defined(DEBUG_FLAGS)
if (tint == 1.0)
@@ -357,13 +371,14 @@ uniform vec4 visible_rect;
uniform float material_radius;
uniform vec2 texture_size;
uniform float blur_radius;
uniform float presentation_alpha;
uniform vec4 tint;
varying vec2 v_coords;
void main() {
vec2 half_size = visible_rect.zw * 0.5;
vec2 d = abs(gl_FragCoord.xy - visible_rect.xy - half_size) - (half_size - vec2(material_radius));
float sdf = length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - material_radius;
float coverage = alpha * (1.0 - smoothstep(-0.5, 0.5, sdf));
float coverage = alpha * presentation_alpha * (1.0 - smoothstep(-0.5, 0.5, sdf));
if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
// Denser Gaussian sampling preserves the same radius and softness without
// the visible grid left by widely spaced taps on detailed wallpapers.
@@ -562,6 +577,7 @@ pub(crate) struct MaterialProgram(GlesPixelProgram);
#[derive(Clone, Debug, PartialEq)]
struct MaterialParameters {
presentation_alpha: f32,
blur: f32,
scene_generation: u64,
sample_geometry: Rectangle<i32, Logical>,
@@ -604,6 +620,12 @@ struct BorderParameters {
radius: f32,
width: f32,
color: [f32; 4],
color_to: [f32; 4],
gradient_line: [f32; 4],
focus_color: [f32; 4],
focus_color_to: [f32; 4],
focus_gradient_line: [f32; 4],
focus_mix: f32,
}
#[derive(Debug)]
@@ -902,6 +924,9 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
state.loop_signal.stop();
return;
}
if state.session_lock.active {
state.lock_frame_presented(&output);
}
let elapsed = render_started.elapsed();
if elapsed > refresh.get() {
missed_deadlines += (elapsed.as_nanos() / refresh.get().as_nanos()) as u64;
@@ -946,6 +971,10 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
}
fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output, refresh: Duration) {
if state.session_lock.active {
state.lock_frame_callbacks(output);
return;
}
let time = state.start_time.elapsed();
for window in state.space.elements() {
window.send_frame(output, time, Some(refresh), |_, _| Some(output.clone()));
@@ -985,6 +1014,58 @@ fn output_elements(
output: &Output,
include_cursor: bool,
) -> Vec<AnimatedWindowRenderElement> {
if state.session_lock.active {
state.configure_lock_surfaces();
let Some(geometry) = state.space.output_geometry(output) else {
return Vec::new();
};
let scale = output.current_scale().fractional_scale();
let background = state
.session_lock
.backgrounds
.entry(output.clone())
.or_insert_with(|| {
smithay::backend::renderer::element::solid::SolidColorBuffer::new(
geometry.size,
[0.0, 0.0, 0.0, 1.0],
)
});
background.resize(geometry.size);
let mut elements = Vec::new();
if let Some(surface) = state
.session_lock
.surfaces
.get(output)
.filter(|surface| surface.alive())
{
elements.extend(
render_elements_from_surface_tree::<
GlesRenderer,
WaylandSurfaceRenderElement<GlesRenderer>,
>(
renderer,
surface.wl_surface(),
(0, 0),
scale,
1.0,
RenderElementKind::Unspecified,
)
.into_iter()
.map(AnimatedWindowRenderElement::from),
);
}
elements.push(
SolidColorRenderElement::from_buffer(
background,
(0, 0),
scale,
1.0,
RenderElementKind::Unspecified,
)
.into(),
);
return elements;
}
if state.wallpaper.poll() {
state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
}
@@ -1082,18 +1163,15 @@ fn output_elements(
} else {
state.focused_window == Some(id)
};
let border_width = if focused {
state.theme_settings.focus_ring_width
} else {
state.theme_settings.border_width
};
let border_color = if focused {
state.theme_settings.accent_color.0
} else {
state.theme_settings.border_color.0
};
let border_color =
color_with_alpha(border_color, close_alpha * decoration_progress as f32);
let focus = state
.window_focus
.get(&id)
.map_or(if focused { 1.0 } else { 0.0 }, |v| v.current);
let border_width = state.theme_settings.border_width
+ (state.theme_settings.focus_ring_width - state.theme_settings.border_width)
* focus;
let border_color = state.theme_settings.border_color.0;
let gradient = state.theme_settings.border_gradient;
if let Some(border) = window_border_element(
state,
@@ -1105,6 +1183,9 @@ fn output_elements(
window_radius,
border_width,
border_color,
gradient,
focus as f32,
close_alpha * decoration_progress as f32,
output,
&programs,
) {
@@ -1263,6 +1344,12 @@ fn overview_chrome_element(
radius: (radius * scale) as f32,
width: (width * scale) as f32,
color,
color_to: color,
gradient_line: [0.0; 4],
focus_color: color,
focus_color_to: color,
focus_gradient_line: [0.0; 4],
focus_mix: 0.0,
};
let program = if key.1 {
rounded_clip_program(state, renderer)?.border
@@ -1472,6 +1559,12 @@ fn rounded_clip_program(
UniformName::new("radius", UniformType::_1f),
UniformName::new("border_width", UniformType::_1f),
UniformName::new("border_color", UniformType::_4f),
UniformName::new("border_color_to", UniformType::_4f),
UniformName::new("gradient_line", UniformType::_4f),
UniformName::new("focus_color", UniformType::_4f),
UniformName::new("focus_color_to", UniformType::_4f),
UniformName::new("focus_gradient_line", UniformType::_4f),
UniformName::new("focus_mix", UniformType::_1f),
];
let shadow_uniforms = [
UniformName::new("shadow_rect", UniformType::_4f),
@@ -1516,6 +1609,9 @@ fn window_border_element(
requested_radius: f64,
requested_width: f64,
color: [f32; 4],
gradient: Option<crate::config::BorderGradient>,
focus_mix: f32,
opacity: f32,
output: &Output,
programs: &RoundedClipPrograms,
) -> Option<PhysicalShaderElement> {
@@ -1525,12 +1621,49 @@ fn window_border_element(
return None;
}
let (from, to, gradient_line) = match gradient {
Some(gradient) => (
gradient.from.0,
gradient.to.0,
border_gradient_line(
physical,
mode.size,
output.current_transform().invert(),
gradient.angle,
),
),
None => (color, color, [0.0; 4]),
};
let (focus_from, focus_to, focus_gradient_line) = match state.theme_settings.focus_ring_gradient
{
Some(g) => (
g.from.0,
g.to.0,
border_gradient_line(
physical,
mode.size,
output.current_transform().invert(),
g.angle,
),
),
None => (
state.theme_settings.accent_color.0,
state.theme_settings.accent_color.0,
[0.0; 4],
),
};
let parameters = BorderParameters {
geometry,
clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()),
radius: scaled_effect_value(requested_radius, geometry, scale),
width,
color,
color: color_with_alpha(from, opacity),
color_to: color_with_alpha(to, opacity),
gradient_line,
focus_color: color_with_alpha(focus_from, opacity),
focus_color_to: color_with_alpha(focus_to, opacity),
focus_gradient_line,
focus_mix,
};
let context = renderer.context_id().erased();
let buffers = state.window_borders.entry(id).or_default();
@@ -1573,6 +1706,12 @@ fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'static>> {
Uniform::new("radius", parameters.radius).into_owned(),
Uniform::new("border_width", parameters.width).into_owned(),
Uniform::new("border_color", parameters.color).into_owned(),
Uniform::new("border_color_to", parameters.color_to).into_owned(),
Uniform::new("gradient_line", parameters.gradient_line).into_owned(),
Uniform::new("focus_color", parameters.focus_color).into_owned(),
Uniform::new("focus_color_to", parameters.focus_color_to).into_owned(),
Uniform::new("focus_gradient_line", parameters.focus_gradient_line).into_owned(),
Uniform::new("focus_mix", parameters.focus_mix).into_owned(),
]
}
@@ -1605,6 +1744,12 @@ fn window_tint_element(
radius: scaled_effect_value(radius, geometry, scale),
width: 0.0,
color,
color_to: color,
gradient_line: [0.0; 4],
focus_color: color,
focus_color_to: color,
focus_gradient_line: [0.0; 4],
focus_mix: 0.0,
};
let uniforms = |p: &BorderParameters| {
vec![
@@ -1921,6 +2066,34 @@ fn framebuffer_clip_rect(
]
}
fn border_gradient_line(
geometry: Rectangle<i32, Physical>,
output_size: Size<i32, Physical>,
transform: Transform,
angle: f64,
) -> [f32; 4] {
let angle = angle.to_radians();
let direction = (angle.cos(), angle.sin());
let width = f64::from(geometry.size.w);
let height = f64::from(geometry.size.h);
let extent = (width * direction.0.abs() + height * direction.1.abs()) * 0.5;
let center = geometry.loc.to_f64() + Point::from((width * 0.5, height * 0.5));
let offset: Point<f64, Physical> = (direction.0 * extent, direction.1 * extent).into();
// Apply the same output-to-framebuffer transform as the rounded clip.
// This keeps angles in window coordinates on rotated/flipped monitors.
let area = transform.transform_size(output_size).to_f64();
let start = transform.transform_point_in(center - offset, &area);
let end = transform.transform_point_in(center + offset, &area);
let delta = end - start;
let length_squared = (delta.x * delta.x + delta.y * delta.y).max(0.000001);
[
start.x as f32,
start.y as f32,
(delta.x / length_squared) as f32,
(delta.y / length_squared) as f32,
]
}
pub(crate) fn redraw_output<R, E>(
renderer: &mut R,
framebuffer: &mut R::Framebuffer<'_>,
@@ -2087,7 +2260,7 @@ fn append_material_surface(
surface,
content_origin.to_physical_precise_round(scale),
scale,
1.0,
crate::effects::surface_opacity(surface),
RenderElementKind::Unspecified,
);
elements.extend(content.into_iter().filter_map(|element| {
@@ -2125,6 +2298,7 @@ fn material_element(
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let (role, generation) = crate::effects::surface_role(surface)?;
let mut material = crate::effects::resolve_material(role, state.theme_settings.material_style);
let presentation_alpha = crate::effects::surface_opacity(surface);
if role == crate::effects::SemanticRole::Panel
&& material.style == crate::config::MaterialStyle::Translucent
{
@@ -2138,7 +2312,11 @@ fn material_element(
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_opacity = if edge_bar {
0.0
} else {
shadow_opacity * f64::from(presentation_alpha)
};
let shadow_geometry = Rectangle::new(
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
geometry.size,
@@ -2157,6 +2335,7 @@ fn material_element(
let sample_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
let sample_physical = sample_geometry.to_physical_precise_round(scale);
let parameters = MaterialParameters {
presentation_alpha,
blur: (blur * scale) as f32,
scene_generation: if blur > 0.0 {
state.backdrop_generation
@@ -2175,9 +2354,14 @@ fn material_element(
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
geometry,
tint: [red, green, blue, material.opacity],
tint: [
red,
green,
blue,
material.opacity * if blur > 0.0 { 1.0 } else { presentation_alpha },
],
generation,
opaque: material.opacity == 1.0 && radius == 0.0,
opaque: material.opacity == 1.0 && presentation_alpha == 1.0 && radius == 0.0,
visible_framebuffer: framebuffer_clip_rect(
geometry.to_physical_precise_round(scale),
mode.size,
@@ -2304,6 +2488,7 @@ fn blur_program(state: &mut Ferese, renderer: &mut GlesRenderer) -> Option<BlurP
UniformName::new("material_radius", UniformType::_1f),
UniformName::new("texture_size", UniformType::_2f),
UniformName::new("blur_radius", UniformType::_1f),
UniformName::new("presentation_alpha", UniformType::_1f),
UniformName::new("tint", UniformType::_4f),
];
match renderer.compile_custom_texture_shader(BLUR_SHADER, &uniforms) {
@@ -2332,6 +2517,7 @@ fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
)
.into_owned(),
Uniform::new("blur_radius", p.blur).into_owned(),
Uniform::new("presentation_alpha", p.presentation_alpha).into_owned(),
Uniform::new("tint", p.tint).into_owned(),
]
}
@@ -2491,10 +2677,67 @@ mod tests {
};
use super::{
color_with_alpha, framebuffer_clip_rect, normalized_scale, resize_content_behavior,
rounded_visual_rect, scaled_visual_rect, shadow_bounds,
border_gradient_line, color_with_alpha, framebuffer_clip_rect, normalized_scale,
resize_content_behavior, rounded_visual_rect, scaled_visual_rect, shadow_bounds,
};
#[test]
fn gradient_direction_survives_every_output_transform() {
let mode = (2560, 1600).into();
for transform in [
Transform::Normal,
Transform::_90,
Transform::_180,
Transform::_270,
Transform::Flipped,
Transform::Flipped90,
Transform::Flipped180,
Transform::Flipped270,
] {
let area = transform.transform_size(mode).to_f64();
let rect: Rectangle<i32, Physical> =
Rectangle::new((110, 60).into(), (800, 600).into());
for (angle, first, last) in [
(0.0, (110.0, 360.0), (910.0, 360.0)),
(90.0, (510.0, 60.0), (510.0, 660.0)),
(45.0, (110.0, 60.0), (910.0, 660.0)),
(135.0, (910.0, 60.0), (110.0, 660.0)),
] {
let line = border_gradient_line(rect, mode, transform, angle);
let progress = |point: smithay::utils::Point<f64, Physical>| {
let point = transform.transform_point_in(point, &area);
(point.x - f64::from(line[0])) * f64::from(line[2])
+ (point.y - f64::from(line[1])) * f64::from(line[3])
};
assert!(
progress(first.into()).abs() < 0.00001,
"{transform:?}, {angle}"
);
assert!(
(progress(last.into()) - 1.0).abs() < 0.00001,
"{transform:?}, {angle}"
);
}
}
}
#[test]
fn gradient_coordinates_follow_fractional_resize_and_translation() {
for scale in [1.0, 1.25, 1.5, 1.8, 2.0] {
for width in [500.0, 733.25, 1000.0] {
let rect = crate::presentation::physical_rect(
ferese_layout::Rect::new(40.25, 60.75, width, 600.5),
(0, 0).into(),
scale,
);
let line = border_gradient_line(rect, (3840, 2160).into(), Transform::Normal, 0.0);
assert!((f64::from(line[0]) - f64::from(rect.loc.x)).abs() < 0.001);
assert!((f64::from(line[2]) * f64::from(rect.size.w) - 1.0).abs() < 0.00001);
assert!(line.iter().all(|component| component.is_finite()));
}
}
}
#[derive(Debug)]
struct DamageElement {
id: Id,
+45
View File
@@ -376,6 +376,23 @@ impl Default for WorkspaceSet {
}
impl WorkspaceSet {
/// Update policy without moving windows or replacing existing column widths.
pub fn reconfigure_defaults(
&mut self,
mode: LayoutMode,
width: ColumnWidth,
strategy: ViewportFocusStrategy,
) {
self.default_layout_mode = mode;
self.default_column_width = width;
self.scrolling_focus_strategy = strategy;
for workspace in self.workspaces.values_mut() {
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
layout.set_default_width(width);
layout.set_focus_strategy(strategy);
}
}
}
pub fn new(
default_layout_mode: LayoutMode,
default_column_width: ColumnWidth,
@@ -994,6 +1011,34 @@ fn finite_or_zero(value: f64) -> f64 {
#[cfg(test)]
mod tests {
#[test]
fn reconfiguration_preserves_windows_and_current_widths_but_updates_defaults() {
let mut workspaces = WorkspaceSet::default();
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let workspace = workspaces.active_id();
workspaces.reconfigure_defaults(
LayoutMode::Tree,
ColumnWidth::Full,
ViewportFocusStrategy::Minimal,
);
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!("existing layout must survive");
};
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
assert_eq!(layout.default_width(), ColumnWidth::Full);
assert_eq!(
workspaces.workspace_for_window(WindowId(1)),
Some(workspace)
);
workspaces.switch_to_numeric(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree);
assert_eq!(
workspaces.workspace_for_window(WindowId(1)),
Some(workspace)
);
}
use super::*;
#[test]
+3
View File
@@ -100,6 +100,9 @@ impl Default for ScrollingLayout {
}
impl ScrollingLayout {
pub fn set_default_width(&mut self, width: ColumnWidth) {
self.default_width = normalized_width(width);
}
pub fn with_default_width(default_width: ColumnWidth) -> Self {
Self {
default_width: normalized_width(default_width),
+5 -3
View File
@@ -49,7 +49,8 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
}
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
| "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel"
| "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "exit" => {
| "close" | "get-focused-window" | "get-workspaces" | "get-outputs" | "reload-config"
| "exit" => {
if !positional.is_empty() {
return Err(format!("{command} does not accept arguments"));
}
@@ -78,7 +79,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
}
fn usage() -> String {
"usage: feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit>\n feresectl <get-focused-window|get-workspaces|get-outputs>".to_owned()
"usage: feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit>\n feresectl <get-focused-window|get-workspaces|get-outputs|reload-config>".to_owned()
}
#[cfg(test)]
@@ -122,6 +123,7 @@ mod tests {
);
assert!(parse_args(["exit".to_owned(), "extra".to_owned()]).is_err());
assert!(parse_args(["focus".to_owned()]).is_err());
assert!(parse_args(["reload-config".to_owned()]).is_err());
assert!(parse_args(["reload-config".to_owned()]).is_ok());
assert!(parse_args(["reload-config".to_owned(), "extra".to_owned()]).is_err());
}
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
config=${XDG_CONFIG_HOME:-$HOME/.config}/ferese/swaylock.conf
if [[ -f $config ]]; then
exec swaylock --config "$config" --daemonize
fi
exec swaylock --color 0f1014 --daemonize
+9 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="ferese_effects_manager_v1" version="2">
<interface name="ferese_effects_manager_v1" version="3">
<description summary="assign semantic material roles to surfaces">
This private global is visible only to clients granted the Ferese effects
capability. Roles describe compositor-owned appearance policy and do not
@@ -48,7 +48,7 @@
</enum>
</interface>
<interface name="ferese_surface_effects_v1" version="2">
<interface name="ferese_surface_effects_v1" version="3">
<description summary="semantic material role for one surface">
The role affects only compositor rendering. It does not change surface
geometry, input, focus, stacking, or the surface's Wayland role.
@@ -88,5 +88,12 @@
</description>
<arg name="regions" type="array"/>
</request>
<request name="set_opacity" since="3">
<description summary="fade the complete surface presentation">
Opacity from 0 to 1000, including surface content, material and shadow.
Values above 1000 are clamped. Input and geometry remain unchanged.
</description>
<arg name="opacity" type="uint"/>
</request>
</interface>
</protocol>
+13 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE.
</copyright>
<interface name="ferese_shell_manager_v1" version="1">
<interface name="ferese_shell_manager_v1" version="2">
<description summary="create a capability-scoped Ferese shell controller">
This private global is visible only to clients granted the shell-control
capability. It does not assign a Wayland surface role.
@@ -35,7 +35,7 @@
<request name="destroy" type="destructor"/>
</interface>
<interface name="ferese_shell_v1" version="1">
<interface name="ferese_shell_v1" version="2">
<description summary="managed-window metadata and shell control">
Window, workspace, and output identifiers are unsigned 64-bit values
encoded as high and low 32-bit words. Each snapshot atomically replaces
@@ -132,5 +132,16 @@
<description summary="marks completion of initial protocol setup"/>
<arg name="version" type="uint"/>
</event>
<event name="config_begin" since="2">
<description summary="complete validated configuration for live reload">
Sent only after the compositor has accepted the complete configuration.
Followed by bounded UTF-8 chunks and config_end. Clients apply only the
completed source, preserving their current appearance if parsing fails.
</description>
</event>
<event name="config_chunk" since="2">
<arg name="source" type="string"/>
</event>
<event name="config_end" since="2"/>
</interface>
</protocol>
+3 -2
View File
@@ -13,7 +13,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop
[[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
# Refuse to overwrite unrelated installations.
for name in ferese ferese-shell feresectl ferese-session; do
for name in ferese ferese-shell feresectl ferese-session ferese-lock; do
link=/usr/local/bin/$name
if [[ -e $link || -L $link ]]; then
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || {
@@ -43,6 +43,7 @@ for name in ferese ferese-shell feresectl; do
install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
done
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session"
install -m 0755 -- "$repo_dir/packaging/ferese-lock" "$release_dir/ferese-lock"
install -m 0644 -- "$repo_dir/packaging/config.toml" "$release_dir/config.example.toml"
if [[ -L $install_root/current ]]; then
@@ -50,7 +51,7 @@ if [[ -L $install_root/current ]]; then
fi
ln -s -- "releases/$release_id" "$install_root/current-$release_id"
mv -Tf -- "$install_root/current-$release_id" "$install_root/current"
for name in ferese ferese-shell feresectl ferese-session; do
for name in ferese ferese-shell feresectl ferese-session ferese-lock; do
ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
done
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"
+1
View File
@@ -11,6 +11,7 @@ jiff.workspace = true
# Keep GPU rendering available as a fallback/override. The shell defaults to
# tiny-skia to avoid a second GPU initialization during desktop startup.
libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
serde.workspace = true
toml.workspace = true
wayland-client.workspace = true
+18 -7
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug, Default)]
pub(crate) struct ShellConfig {
pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig,
pub(crate) theme: ShellTheme,
@@ -111,6 +112,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)]
struct FereseConfig {
#[serde(default)]
animations: crate::motion::Settings,
#[serde(default)]
appearance: AppearanceConfig,
#[serde(default)]
@@ -284,13 +287,24 @@ pub(crate) fn load() -> ShellConfig {
return ShellConfig::default();
};
match toml::from_str::<FereseConfig>(&source) {
match parse_source(&source) {
Ok(config) => config,
Err(error) => {
eprintln!("ferese-shell: failed to read {}: {error}", path.display());
ShellConfig::default()
}
}
}
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, toml::de::Error> {
match toml::from_str::<FereseConfig>(source) {
Ok(config) => {
let mut theme = shell_theme(&config.theme);
theme.material_radius =
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
ShellConfig {
Ok(ShellConfig {
animations: config.animations,
font_family: config.theme.typography.font_family,
wallpaper: config.theme.background,
theme,
@@ -298,12 +312,9 @@ pub(crate) fn load() -> ShellConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status
},
}
}
Err(error) => {
eprintln!("ferese-shell: failed to read {}: {error}", path.display());
ShellConfig::default()
})
}
Err(error) => Err(error),
}
}
+50 -2
View File
@@ -36,6 +36,7 @@ pub(crate) struct OutputSnapshot {
#[derive(Clone, Debug)]
pub(crate) struct WorkspaceSnapshot {
pub(crate) id: u64,
pub(crate) name: String,
pub(crate) output: Option<u64>,
pub(crate) active: bool,
}
@@ -58,12 +59,14 @@ pub(crate) struct ShellControl {
}
pub(crate) struct ControlPoll {
pub(crate) config: Option<String>,
pub(crate) snapshot: Option<ShellSnapshot>,
pub(crate) overview_active: Option<bool>,
pub(crate) disconnected: bool,
}
enum ControlUpdate {
Config(String),
Snapshot(ShellSnapshot),
OverviewState(bool),
Disconnected,
@@ -74,7 +77,7 @@ impl ShellControl {
let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=1, ())?;
let manager = globals.bind::<FereseShellManagerV1, _, _>(&qh, 1..=2, ())?;
let shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel();
let mut state = ControlState::new(sender);
@@ -97,6 +100,7 @@ impl ShellControl {
pub(crate) fn poll(&self) -> ControlPoll {
let mut poll = ControlPoll {
config: None,
snapshot: None,
overview_active: None,
disconnected: false,
@@ -104,6 +108,7 @@ impl ShellControl {
loop {
match self.updates.try_recv() {
Ok(ControlUpdate::Config(source)) => poll.config = Some(source),
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => {
@@ -156,6 +161,7 @@ fn control_connection() -> Result<Connection, Box<dyn Error>> {
}
struct ControlState {
config: Option<String>,
sender: Sender<ControlUpdate>,
pending: ShellSnapshot,
serial: Option<u32>,
@@ -165,6 +171,7 @@ impl ControlState {
fn new(sender: Sender<ControlUpdate>) -> Self {
Self {
sender,
config: None,
pending: ShellSnapshot::default(),
serial: None,
}
@@ -193,6 +200,15 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
_qh: &QueueHandle<Self>,
) {
match event {
ferese_shell_v1::Event::ConfigBegin => state.config = Some(String::new()),
ferese_shell_v1::Event::ConfigChunk { source } => {
append_config_chunk(&mut state.config, &source);
}
ferese_shell_v1::Event::ConfigEnd => {
if let Some(source) = state.config.take() {
let _ = state.sender.send(ControlUpdate::Config(source));
}
}
ferese_shell_v1::Event::SnapshotBegin { serial } => {
state.serial = Some(serial);
state.pending = ShellSnapshot::default();
@@ -215,13 +231,14 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
workspace_lo,
output_hi,
output_lo,
name: _,
name,
active,
} => {
let output = join_id(output_hi, output_lo);
state.pending.workspaces.push(WorkspaceSnapshot {
id: join_id(workspace_hi, workspace_lo),
name,
output: (output != 0).then_some(output),
active: active != 0,
});
@@ -276,3 +293,34 @@ fn split_id(id: u64) -> (u32, u32) {
fn join_id(hi: u32, lo: u32) -> u64 {
(u64::from(hi) << 32) | u64::from(lo)
}
fn append_config_chunk(config: &mut Option<String>, source: &str) {
if let Some(buffer) = config {
if buffer.len() + source.len() > 60 * 1024 {
*config = None;
} else {
buffer.push_str(source);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_transfer_is_bounded_and_requires_begin() {
let mut config = None;
append_config_chunk(&mut config, "ignored");
assert!(config.is_none());
config = Some(String::new());
append_config_chunk(&mut config, "🌲");
append_config_chunk(&mut config, "theme");
assert_eq!(config.take().as_deref(), Some("🌲theme"));
config = Some("a".repeat(60 * 1024));
append_config_chunk(&mut config, "overflow");
assert!(config.is_none());
append_config_chunk(&mut config, "ignored after overflow");
assert!(config.is_none());
}
}
+208 -161
View File
@@ -19,7 +19,9 @@ use cosmic::iced::event::{self, PlatformSpecific, wayland};
use cosmic::iced::platform_specific::{
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings},
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer},
shell::commands::layer_surface::{destroy_layer_surface, set_input_zone},
shell::commands::layer_surface::{
destroy_layer_surface, set_exclusive_zone, set_input_zone, set_margin, set_size,
},
};
use cosmic::iced::{
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window,
@@ -42,12 +44,27 @@ use crate::control::{ShellControl, ShellSnapshot};
const APP_ID: &str = "dev.ferese.Shell";
static SHELL_FONT: std::sync::OnceLock<cosmic::font::Font> = std::sync::OnceLock::new();
static SHELL_FONT: std::sync::OnceLock<std::sync::RwLock<cosmic::font::Font>> =
std::sync::OnceLock::new();
fn configured_font(family: Option<&str>) -> cosmic::font::Font {
family.map_or_else(cosmic::font::default, |family| {
// Font names require static storage. Intern repeated families and cap
// the cache so live theme edits cannot leak unlimited strings.
static FONTS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
> = std::sync::OnceLock::new();
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
if let Some(font) = fonts.get(family) {
return *font;
}
if fonts.len() >= 64 {
return cosmic::font::default();
}
let family = Box::leak(family.to_owned().into_boxed_str());
cosmic::font::Font::with_name(family)
let font = cosmic::font::Font::with_name(family);
fonts.insert(family.to_owned(), font);
font
})
}
@@ -56,7 +73,12 @@ fn configured_font(family: Option<&str>) -> cosmic::font::Font {
fn text<'a>(
content: impl Into<std::borrow::Cow<'a, str>> + 'a,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
cosmic::widget::text(content).font(*SHELL_FONT.get_or_init(cosmic::font::default))
cosmic::widget::text(content).font(
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
.read()
.unwrap(),
)
}
#[derive(Clone, Copy)]
@@ -66,11 +88,6 @@ struct BarMetrics {
icon_size: u16,
overview_icon_size: u16,
control_height: f32,
workspace_hit_width: f32,
active_marker_width: f32,
active_marker_height: f32,
dot_marker_size: f32,
empty_marker_size: f32,
}
impl From<ShellTheme> for BarMetrics {
@@ -85,11 +102,6 @@ impl From<ShellTheme> for BarMetrics {
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,
}
}
}
@@ -103,6 +115,7 @@ fn main() -> cosmic::iced::Result {
unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
}
let mut config = config::load();
motion::configure(config.animations);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
if compositor_wallpaper {
// The compositor owns one GPU image, shared across output renderers;
@@ -110,7 +123,7 @@ fn main() -> cosmic::iced::Result {
config.wallpaper.path = None;
}
let shell_font = configured_font(config.font_family.as_deref());
let _ = SHELL_FONT.set(shell_font);
let _ = SHELL_FONT.set(std::sync::RwLock::new(shell_font));
// Decode alongside toolkit/GPU initialization, never during a UI draw.
let wallpaper = config.wallpaper.path.clone().map(|path| {
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
@@ -173,7 +186,6 @@ enum Message {
),
Tick,
ActivateWorkspace(u64),
ActivateWindow(u64),
ToggleOverview,
StatusTick,
AnimateMenu,
@@ -252,7 +264,11 @@ impl cosmic::Application for FereseShell {
}),
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()) {
if self
.menu
.as_ref()
.is_some_and(|menu| menu.animating() || menu.motion.closing())
{
cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
} else {
Subscription::none()
@@ -264,6 +280,9 @@ impl cosmic::Application for FereseShell {
if let Some(menu) = &self.menu
&& let Some(effects) = &menu.effects
{
if let Err(error) = effects.set_opacity(menu.progress()) {
eprintln!("ferese-shell: could not update popup opacity: {error}");
}
let regions = menu.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update card materials: {error}");
@@ -295,6 +314,9 @@ impl cosmic::Application for FereseShell {
eprintln!("ferese-shell: popover material unavailable: {error}")
}
}
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
}
}
Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
@@ -310,7 +332,7 @@ impl cosmic::Application for FereseShell {
}
Task::none()
}
Message::AnimateMenu => Task::none(),
Message::AnimateMenu => self.animate_menu(),
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
Message::OpenMenuOn(id, kind, anchor) => {
if self.bar_surface_id != id {
@@ -343,13 +365,14 @@ impl cosmic::Application for FereseShell {
action,
status::Action::Notifications | status::Action::Settings
) {
return self.destroy_menu();
return self.close_menu();
}
Task::none()
}
Message::Event(event, id) => self.handle_event(event, id),
Message::Tick => {
self.clock = current_time();
let mut reload_task = Task::none();
if let Some(control) = &self.control {
let poll = control.poll();
@@ -359,9 +382,12 @@ impl cosmic::Application for FereseShell {
if let Some(active) = poll.overview_active {
self.overview_active = active;
}
if let Some(source) = poll.config {
reload_task = self.reload_config(source);
}
if let Some(snapshot) = poll.snapshot {
self.snapshot = snapshot;
let mut tasks = Vec::new();
let mut tasks = vec![reload_task];
for entry in &mut self.outputs {
let output = self
.snapshot
@@ -392,13 +418,7 @@ impl cosmic::Application for FereseShell {
return Task::batch(tasks);
}
}
Task::none()
}
Message::ActivateWindow(id) => {
if let Some(control) = &self.control {
control.activate_window(id);
}
Task::none()
reload_task
}
Message::ActivateWorkspace(id) => {
if let Some(control) = &self.control {
@@ -432,6 +452,62 @@ impl cosmic::Application for FereseShell {
}
impl FereseShell {
fn reload_config(&mut self, source: String) -> Task<Message> {
let mut config = match config::parse_source(&source) {
Ok(config) => config,
Err(error) => {
eprintln!("ferese-shell: reload rejected; keeping current config: {error}");
return Task::none();
}
};
if std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some() {
config.wallpaper.path = None;
}
if self.config.font_family != config.font_family {
*SHELL_FONT
.get_or_init(|| std::sync::RwLock::new(cosmic::font::default()))
.write()
.unwrap() = configured_font(config.font_family.as_deref());
}
motion::configure(config.animations);
if self.config.animations != config.animations
&& let Some(menu) = &mut self.menu
{
menu.motion.update_settings(config.animations);
}
self.status_service
.update_settings(config.status.settings_command.clone());
let old = self.config.theme;
let theme = config.theme;
let geometry_changed = old.bar_height != theme.bar_height
|| old.bar_margin_top != theme.bar_margin_top
|| old.bar_margin_horizontal != theme.bar_margin_horizontal
|| old.bar_window_gap != theme.bar_window_gap;
self.config = config;
if !geometry_changed {
return Task::none();
}
let mut tasks = Vec::new();
for entry in &self.outputs {
tasks.push(set_size(
entry.bar,
None,
Some(theme.bar_height.round() as u32),
));
tasks.push(set_margin(
entry.bar,
theme.bar_margin_top,
theme.bar_margin_horizontal,
0,
theme.bar_margin_horizontal,
));
tasks.push(set_exclusive_zone(
entry.bar,
(theme.bar_height.round() as i32).saturating_add(theme.bar_window_gap),
));
}
Task::batch(tasks)
}
fn output_event(
&mut self,
event: wayland::OutputEvent,
@@ -577,13 +653,23 @@ impl FereseShell {
if self.outputs.iter().any(|entry| entry.bar == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == id) =>
{
// Iced emits the first xdg_popup configure as Window::Opened,
// not Popup::Configured. This is the popup's ready signal.
if let Some(menu) = &mut self.menu
&& menu.id == id
{
menu.motion.begin(Instant::now());
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
}
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(),
}) if self.menu.is_some() => self.close_menu(),
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
event,
surface,
@@ -596,6 +682,7 @@ impl FereseShell {
wayland::PopupEvent::Done => {
self.menu = None;
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
return Task::none();
}
wayland::PopupEvent::Focused => {
if menu.effects.is_none() {
@@ -612,6 +699,7 @@ impl FereseShell {
}
}
wayland::PopupEvent::Configured { .. } => {
menu.motion.begin(Instant::now());
if menu.effects.is_none() {
match EffectsBinding::attach_role(
&surface,
@@ -627,6 +715,9 @@ impl FereseShell {
}
_ => {}
}
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
}
Task::none()
}
@@ -706,25 +797,23 @@ impl FereseShell {
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)
.spacing(3)
.align_y(cosmic::iced::Alignment::Center);
workspace_row = workspace_row.push(
workspace_row = workspace_row.push(motion::button(
button::custom(overview_control(bar, color(shell_theme.text_primary)))
.height(bar.control_height)
.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)),
);
color(shell_theme.text_primary),
self.overview_active,
1.0,
));
for workspace in self
.snapshot
.workspaces
.iter()
.filter(|workspace| self.overview_active && workspace.output == focused_output_id)
.filter(|workspace| workspace_on_bar(workspace.output, focused_output_id))
{
let occupied = self
.snapshot
@@ -733,50 +822,35 @@ impl FereseShell {
.any(|window| window.workspace == workspace.id);
let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme);
let workspace_button = button::custom(indicator)
.name(format!(
"Workspace {}{}",
workspace.name,
if workspace.active { ", active" } else { "" }
))
.height(bar.control_height)
.padding(0)
.class(status_ui::button_style(
color(shell_theme.text_primary),
false,
1.0,
))
.on_press(cosmic::Action::App(Message::ActivateWorkspace(
workspace.id,
)));
workspace_row = workspace_row.push(workspace_button);
let selector = motion::button(
workspace_button,
color(if workspace.active {
shell_theme.accent
} else {
shell_theme.text_primary
}),
false,
1.0,
);
// Iced tooltips are overlays inside this bar-height layer surface;
// viewport clamping puts them back over the selector. Keep the
// accessible button name, without an overlay stealing its target.
workspace_row = workspace_row.push(selector);
}
let foreground = color(shell_theme.text_primary);
let mut windows = row::with_capacity(self.snapshot.windows.len())
.spacing(4)
.align_y(cosmic::iced::Alignment::Center);
for window in self.snapshot.windows.iter().filter(|window| {
focused_output.is_some_and(|output| window.workspace == output.active_workspace)
}) {
let label = compact_window_label(window);
let foreground = color(if window.focused {
shell_theme.text_primary
} else {
shell_theme.text_muted
});
windows = windows.push(
button::custom(
container(
text(label)
.size(bar.text_size)
.wrapping(cosmic::iced::core::text::Wrapping::None)
.class(theme::Text::Color(foreground)),
)
.center_y(bar.control_height),
)
.padding([0, 8])
.height(bar.control_height)
.class(status_ui::button_style(foreground, window.focused, 1.0))
.on_press(cosmic::Action::App(Message::ActivateWindow(window.id))),
);
}
let windows = cosmic::iced::widget::scrollable(windows)
let left = cosmic::iced::widget::scrollable(workspace_row)
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
cosmic::iced::widget::scrollable::Scrollbar::default()
.width(0)
@@ -784,9 +858,6 @@ impl FereseShell {
))
.width(Length::Fill)
.height(bar.control_height);
let left = row![workspace_row, windows]
.spacing(8)
.align_y(cosmic::iced::Alignment::Center);
let clock = container(
text(&self.clock)
.size(bar.text_size)
@@ -943,70 +1014,46 @@ fn workspace_indicator(
bar: BarMetrics,
shell_theme: ShellTheme,
) -> Element<'static, cosmic::Action<Message>> {
let (width, height, color, radius) = if active {
(
bar.active_marker_width,
bar.active_marker_height,
color(shell_theme.accent),
2.0,
)
} else if occupied {
(
bar.dot_marker_size,
bar.dot_marker_size,
color_with_opacity(shell_theme.text_muted, 0.78),
3.0,
)
} else {
(
bar.empty_marker_size,
bar.empty_marker_size,
color_with_opacity(shell_theme.text_muted, 0.42),
2.0,
)
};
let marker = container(text(""))
.width(width)
.height(height)
.width(if active { 18.0 } else { 6.0 })
.height(6)
.class(theme::Container::custom(move |_| {
workspace_marker_style(color, radius)
workspace_selector_style(active, occupied, shell_theme)
}));
container(marker)
.width(bar.workspace_hit_width)
.width(28)
.height(bar.control_height)
.align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center)
.into()
}
fn workspace_marker_style(color: Color, radius: f32) -> container::Style {
container::Style {
background: Some(Background::Color(color)),
border: Border {
radius: radius.into(),
..Border::default()
},
snap: true,
..container::Style::default()
}
fn workspace_on_bar(workspace_output: Option<u64>, bar_output: Option<u64>) -> bool {
matches!((workspace_output, bar_output), (Some(workspace), Some(bar)) if workspace == bar)
}
fn display_app_name(window: &control::WindowSnapshot) -> String {
let identity = window
.app_id
.rsplit('.')
.find(|part| !part.is_empty())
.unwrap_or(&window.app_id);
if identity.is_empty() {
window.title.clone()
} else {
let mut characters = identity.chars();
characters
.next()
.map(|first| first.to_uppercase().collect::<String>() + characters.as_str())
.unwrap_or_else(|| "Desktop".to_owned())
fn workspace_selector_style(
active: bool,
occupied: bool,
shell_theme: ShellTheme,
) -> container::Style {
container::Style {
background: if active {
Some(Background::Color(color(shell_theme.accent)))
} else if occupied {
Some(Background::Color(color_with_opacity(
shell_theme.text_primary,
0.78,
)))
} else {
None
},
border: Border {
color: color_with_opacity(shell_theme.text_muted, 0.55),
width: if !active && !occupied { 1.0 } else { 0.0 },
radius: 3.0.into(),
},
..Default::default()
}
}
@@ -1030,22 +1077,6 @@ fn format_bar_time(now: &Zoned) -> String {
)
}
fn compact_window_label(window: &crate::control::WindowSnapshot) -> String {
let title = window.title.trim();
let label = if title.is_empty() {
display_app_name(window)
} else {
title.to_owned()
};
let mut characters = label.chars();
let prefix: String = characters.by_ref().take(24).collect();
if characters.next().is_some() {
format!("{prefix}…")
} else {
prefix
}
}
fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
cosmic::iced::theme::Style {
background_color: Color::TRANSPARENT,
@@ -1131,6 +1162,7 @@ struct EffectsBinding {
surface: FereseSurfaceEffectsV1,
_queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>,
}
impl EffectsBinding {
@@ -1155,13 +1187,16 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle();
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=2, ())?;
let manager = globals.bind::<FereseEffectsManagerV1, _, _>(&qh, 1..=3, ())?;
let effects = manager.get_surface_effects(surface, &qh, ());
if let Some(role) = role {
effects.set_role(role);
} else {
effects.clear_role();
if effects.version() >= 3 {
effects.set_opacity(0);
}
}
connection.flush()?;
@@ -1171,6 +1206,7 @@ impl EffectsBinding {
surface: effects,
_queue: queue,
regions: Default::default(),
opacity: std::cell::Cell::new(None),
})
}
@@ -1196,6 +1232,7 @@ impl EffectsBinding {
}
fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
self.set_opacity(1.0)?;
if visible {
self.surface
.set_role(ferese_surface_effects_v1::Role::Panel);
@@ -1206,6 +1243,15 @@ impl EffectsBinding {
self.connection.flush()?;
Ok(())
}
fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> {
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) {
self.surface.set_opacity(opacity);
self.connection.flush()?;
self.opacity.set(Some(opacity));
}
Ok(())
}
}
struct EffectsState;
@@ -1300,26 +1346,27 @@ mod tests {
}
#[test]
fn window_list_labels_use_titles_and_fall_back_to_the_app_name() {
let snapshot = snapshot_with_fullscreen_window(7);
let mut window = snapshot.windows[0].clone();
window.title = " editor.rs — Project ".to_owned();
assert_eq!(compact_window_label(&window), "editor.rs — Project");
window.title = " ".to_owned();
assert_eq!(compact_window_label(&window), display_app_name(&window));
fn workspace_selector_has_distinct_active_paint() {
let theme = ShellTheme::default();
let active = workspace_selector_style(true, false, theme);
let occupied = workspace_selector_style(false, true, theme);
let inactive = workspace_selector_style(false, false, theme);
assert!(active.background.is_some());
assert_eq!(
active.background,
Some(Background::Color(color(theme.accent)))
);
assert!(occupied.background.is_some());
assert!(inactive.background.is_none());
assert_eq!(inactive.border.width, 1.0);
}
#[test]
fn window_list_labels_truncate_unicode_without_breaking_characters() {
let snapshot = snapshot_with_fullscreen_window(7);
let mut window = snapshot.windows[0].clone();
window.title = "界".repeat(25);
assert_eq!(
compact_window_label(&window),
format!("{}…", "界".repeat(24))
);
window.title = "界".repeat(24);
assert_eq!(compact_window_label(&window), window.title);
fn workspace_selector_is_output_local() {
assert!(workspace_on_bar(Some(1), Some(1)));
assert!(!workspace_on_bar(Some(2), Some(1)));
assert!(!workspace_on_bar(None, Some(1)));
assert!(!workspace_on_bar(None, None));
}
#[test]
+437
View File
@@ -6,6 +6,322 @@ use cosmic::{Element, Theme};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>;
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
#[serde(default)]
pub(crate) struct Settings {
enabled: bool,
reduced_motion: bool,
speed: f64,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
reduced_motion: false,
speed: 1.0,
}
}
}
impl Settings {
fn hover_duration(self) -> std::time::Duration {
self.duration(120.0)
}
pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration {
if !self.enabled || self.reduced_motion {
return std::time::Duration::ZERO;
}
let speed = if self.speed.is_finite() && self.speed > 0.0 {
self.speed.clamp(0.01, 100.0)
} else {
1.0
};
std::time::Duration::from_secs_f64(milliseconds / 1000.0 / speed)
}
}
static SETTINGS: std::sync::OnceLock<std::sync::RwLock<Settings>> = std::sync::OnceLock::new();
pub(crate) fn configure(settings: Settings) {
*SETTINGS
.get_or_init(|| std::sync::RwLock::new(Settings::default()))
.write()
.unwrap() = settings;
}
pub(crate) struct PopupMotion {
start: f32,
target: f32,
started: Option<std::time::Instant>,
settings: Settings,
}
impl PopupMotion {
pub(crate) fn update_settings(&mut self, settings: Settings) {
let now = std::time::Instant::now();
self.start = self.progress_at(now);
self.settings = settings;
self.started = Some(now);
}
pub(crate) fn new(settings: Settings) -> Self {
Self {
start: 0.0,
target: 1.0,
started: None,
settings,
}
}
pub(crate) fn begin(&mut self, now: std::time::Instant) {
if self.started.is_none() {
self.started = Some(now);
}
}
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
let duration = self
.settings
.duration(if self.target == 1.0 { 200.0 } else { 140.0 });
if duration.is_zero() {
return self.target;
}
let Some(started) = self.started else {
return self.start;
};
let t = (now.saturating_duration_since(started).as_secs_f32() / duration.as_secs_f32())
.min(1.0);
if t == 1.0 {
return self.target;
}
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t)
}
pub(crate) fn progress(&self) -> f32 {
self.progress_at(std::time::Instant::now())
}
pub(crate) fn closing(&self) -> bool {
self.target == 0.0
}
pub(crate) fn animating(&self) -> bool {
self.progress() != self.target
}
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
self.start = self.progress_at(now);
self.target = target;
self.started = Some(now);
}
}
/// Animate paint only; the original button retains its layout, hit target,
/// keyboard handling, pressed state, and accessibility identity.
pub(crate) fn button<'a, M: Clone + 'a>(
button: cosmic::widget::button::Button<'a, M>,
foreground: cosmic::iced::Color,
selected: bool,
opacity: f32,
) -> Element<'a, M> {
let progress = std::rc::Rc::new(std::cell::Cell::new(0.0));
let active = progress.clone();
let hovered = progress.clone();
let paint = move |progress: f32, pressed: bool| cosmic::widget::button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: 6.0.into(),
background: Some(cosmic::iced::Background::Color(cosmic::iced::Color {
a: if pressed {
0.20 * opacity
} else {
((if selected { 0.14 } else { 0.0 })
+ progress * if selected { 0.02 } else { 0.08 })
* opacity
},
..foreground
})),
..Default::default()
};
let content = button
.class(cosmic::theme::Button::Custom {
active: Box::new(move |_, _| paint(active.get(), false)),
hovered: Box::new(move |_, _| paint(hovered.get(), false)),
pressed: Box::new(move |_, _| paint(1.0, true)),
disabled: Box::new(move |_| paint(0.0, false)),
})
.into();
Element::new(Hover {
content,
progress,
duration: SETTINGS
.get()
.map_or_else(Settings::default, |settings| *settings.read().unwrap())
.hover_duration(),
})
}
#[derive(Default)]
struct HoverState {
current: f32,
start: f32,
target: f32,
started: Option<std::time::Instant>,
}
impl HoverState {
fn advance(
&mut self,
target: f32,
now: std::time::Instant,
duration: std::time::Duration,
) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
self.started = Some(now);
}
let t = if duration.is_zero() {
1.0
} else {
self.started.map_or(1.0, |start| {
(now.saturating_duration_since(start).as_secs_f32() / duration.as_secs_f32())
.min(1.0)
})
};
self.current = self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t);
if t == 1.0 {
self.current = target;
self.started = None;
}
self.current != target
}
}
struct Hover<'a, M> {
content: Element<'a, M>,
progress: std::rc::Rc<std::cell::Cell<f32>>,
duration: std::time::Duration,
}
impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
fn a11y_nodes(
&self,
layout: Layout<'_>,
tree: &widget::Tree,
cursor: mouse::Cursor,
) -> iced_accessibility::A11yTree {
self.content
.as_widget()
.a11y_nodes(layout, &tree.children[0], cursor)
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<HoverState>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(HoverState::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(&self.content)]
}
fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.content));
}
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(&mut tree.children[0], 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 now = std::time::Instant::now();
let hovered = cursor.is_over(layout.bounds()) && cursor.is_over(*viewport);
let state = tree.state.downcast_mut::<HoverState>();
if state.advance(if hovered { 1.0 } else { 0.0 }, now, self.duration) {
shell.request_redraw_at(now + std::time::Duration::from_millis(16));
}
self.progress.set(state.current);
self.content.as_widget_mut().update(
&mut tree.children[0],
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,
) {
self.progress
.set(tree.state.downcast_ref::<HoverState>().current);
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
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.children[0],
layout,
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(&mut tree.children[0], layout, renderer, operation);
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &cosmic::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<cosmic::iced::advanced::overlay::Element<'a, M, Theme, cosmic::Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
pub fn animated<'a, M: 'a>(
content: Element<'a, M>,
progress: f32,
@@ -181,6 +497,127 @@ impl widget::Operation for CollectRegions {
mod tests {
use super::*;
#[test]
fn popup_waits_for_configuration_then_opens_and_closes_faster() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings::default());
assert_eq!(
motion.progress_at(now + std::time::Duration::from_secs(30)),
0.0
);
motion.begin(now);
// Later configure/focus notifications must not restart the clock.
motion.begin(now + std::time::Duration::from_millis(90));
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(100)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(200)),
1.0
);
motion.retarget(0.0, now + std::time::Duration::from_millis(200));
assert!(motion.closing());
assert!(
(motion.progress_at(now + std::time::Duration::from_millis(270)) - 0.5).abs() < 0.00001
);
assert_eq!(
motion.progress_at(now + std::time::Duration::from_millis(340)),
0.0
);
}
#[test]
fn popup_reversal_and_motion_policy_are_consistent() {
let now = std::time::Instant::now();
let mut motion = PopupMotion::new(Settings {
speed: 0.75,
..Settings::default()
});
motion.begin(now);
let reverse = now + std::time::Duration::from_millis(120);
let before = motion.progress_at(reverse);
motion.retarget(0.0, reverse);
assert_eq!(motion.progress_at(reverse), before);
let reopen = reverse + std::time::Duration::from_millis(60);
let before = motion.progress_at(reopen);
motion.retarget(1.0, reopen);
assert_eq!(motion.progress_at(reopen), before);
assert_eq!(
motion.progress_at(reopen + std::time::Duration::from_secs(1)),
1.0
);
for settings in [
Settings {
reduced_motion: true,
..Settings::default()
},
Settings {
enabled: false,
..Settings::default()
},
] {
let mut motion = PopupMotion::new(settings);
assert_eq!(motion.progress_at(now), 1.0);
motion.retarget(0.0, now);
assert_eq!(motion.progress_at(now), 0.0);
}
}
#[test]
fn hover_fades_settle_and_idle_does_not_schedule_frames() {
let now = std::time::Instant::now();
let duration = std::time::Duration::from_millis(120);
let mut hover = HoverState::default();
assert!(!hover.advance(0.0, now, duration));
assert!(hover.advance(1.0, now + std::time::Duration::from_secs(10), duration));
assert_eq!(hover.current, 0.0);
let start = now + std::time::Duration::from_secs(10);
assert!(hover.advance(1.0, start + duration / 2, duration));
assert!((hover.current - 0.5).abs() < 0.00001);
assert!(!hover.advance(1.0, start + duration, duration));
assert_eq!(hover.current, 1.0);
assert!(!hover.advance(1.0, start + duration * 10, duration));
assert!(hover.advance(0.0, start + duration * 11, duration));
assert!(!hover.advance(0.0, start + duration * 12, duration));
assert_eq!(hover.current, 0.0);
}
#[test]
fn hover_reversals_preserve_current_paint() {
let now = std::time::Instant::now();
let duration = std::time::Duration::from_millis(120);
let mut hover = HoverState::default();
hover.advance(1.0, now, duration);
hover.advance(1.0, now + duration / 2, duration);
let before = hover.current;
assert!(hover.advance(0.0, now + duration / 2, duration));
assert_eq!(hover.current, before);
hover.advance(0.0, now + duration, duration);
assert!(hover.current > 0.0 && hover.current < before);
}
#[test]
fn hover_obeys_global_motion_policy() {
assert_eq!(
Settings {
speed: 0.75,
..Settings::default()
}
.hover_duration(),
std::time::Duration::from_millis(160)
);
for settings in [
Settings {
enabled: false,
..Settings::default()
},
Settings {
reduced_motion: true,
..Settings::default()
},
] {
assert!(settings.hover_duration().is_zero());
let mut hover = HoverState::default();
assert!(!hover.advance(1.0, std::time::Instant::now(), settings.hover_duration()));
assert_eq!(hover.current, 1.0);
}
}
#[test]
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
let mut collector = CollectRegions(Vec::new());
let id = widget::Id::new("ferese-blur-card");
+8
View File
@@ -76,12 +76,15 @@ pub struct Update {
pub error: Option<String>,
}
pub struct Service {
settings: Arc<Mutex<Option<Vec<String>>>>,
tx: SyncSender<(u64, Action)>,
rx: Receiver<Update>,
pub generation: u64,
}
impl Service {
pub fn start(settings: Option<Vec<String>>) -> Self {
let live_settings = Arc::new(Mutex::new(settings));
let settings = live_settings.clone();
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
@@ -132,6 +135,7 @@ impl Service {
state.0 = id;
state.1 = true;
}
let settings = settings.lock().unwrap().clone();
let error = execute(&action, settings.as_deref()).err();
let mut state = shared.0.lock().unwrap();
state.1 = false;
@@ -143,6 +147,7 @@ impl Service {
shared.1.notify_one();
});
Self {
settings: live_settings,
tx,
rx,
generation: 0,
@@ -156,6 +161,9 @@ impl Service {
self.generation = generation;
Ok(())
}
pub fn update_settings(&self, settings: Option<Vec<String>>) {
*self.settings.lock().unwrap() = settings;
}
pub fn poll(&self) -> Option<Update> {
self.rx.try_iter().last()
}
+70 -49
View File
@@ -5,7 +5,6 @@ use cosmic::widget::{column, slider};
use status::{Action, Snapshot};
const DEVICE_LIST_HEIGHT: f32 = 180.0;
const OPEN_MS: f32 = 160.0;
fn device_list_height(count: usize) -> Option<f32> {
(count > 4).then_some(DEVICE_LIST_HEIGHT)
@@ -48,30 +47,30 @@ impl Menu {
pub struct OpenMenu {
pub id: window::Id,
pub kind: Menu,
opened: Instant,
pub motion: super::motion::PopupMotion,
pub confirm: Option<Action>,
pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions,
}
impl OpenMenu {
pub fn progress(&self) -> f32 {
let ease = |t: f32| {
let t = t.clamp(0.0, 1.0);
1.0 - (1.0 - t).powi(3)
};
ease(self.opened.elapsed().as_secs_f32() * 1000.0 / OPEN_MS)
self.motion.progress()
}
pub fn animating(&self) -> bool {
self.progress() < 1.0
self.motion.animating()
}
}
impl FereseShell {
pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle<i32>) -> Task<Message> {
if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) {
// Close atomically. Per-widget alpha cannot fade SVG/text caches
// as one surface and leaves bright fragments during the fade.
return self.destroy_menu();
if let Some(menu) = &mut self.menu {
if menu.motion.closing() {
menu.motion.retarget(1.0, Instant::now());
return Task::none();
}
}
return self.close_menu();
}
let destroy = self.destroy_menu();
if !kind.available(&self.status) {
@@ -81,7 +80,7 @@ impl FereseShell {
self.menu = Some(OpenMenu {
id,
kind,
opened: Instant::now(),
motion: super::motion::PopupMotion::new(self.config.animations),
confirm: None,
effects: None,
regions: Default::default(),
@@ -124,6 +123,36 @@ impl FereseShell {
))
})
}
pub fn close_menu(&mut self) -> Task<Message> {
let Some(menu) = &mut self.menu else {
return Task::none();
};
if menu.motion.closing() {
return Task::none();
}
if menu
.effects
.as_ref()
.is_some_and(|effects| effects.surface.version() >= 3)
{
menu.motion.retarget(0.0, Instant::now());
if menu.animating() {
return Task::none();
}
}
self.destroy_menu()
}
pub fn animate_menu(&mut self) -> Task<Message> {
if let Some(menu) = &self.menu {
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
if menu.motion.closing() && !menu.animating() {
return self.destroy_menu();
}
}
Task::none()
}
pub fn optimistic_status(&mut self, action: &Action) {
match action {
Action::Volume(v) => {
@@ -221,7 +250,6 @@ impl FereseShell {
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
])
.height(metrics.height)
.class(button_style(foreground, selected, 1.0))
.on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::OpenMenu(
kind,
@@ -233,7 +261,7 @@ impl FereseShell {
},
))
});
controls = controls.push(control);
controls = controls.push(motion::button(control, foreground, selected, 1.0));
}
controls.into()
}
@@ -243,7 +271,8 @@ impl FereseShell {
return text("").into();
};
let theme = self.config.theme;
let p = menu.progress();
// The compositor fades the complete surface, not individual caches.
let p = 1.0;
let kind = menu.kind;
let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, p);
@@ -615,7 +644,7 @@ impl FereseShell {
}));
// Keep the outer surface transparent; only inner cards paint a background.
cosmic::widget::autosize::autosize(
super::motion::animated(panel.into(), p, menu.regions.clone()),
super::motion::animated(panel.into(), menu.progress(), menu.regions.clone()),
cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
)
.limits(
@@ -661,12 +690,15 @@ fn status_summary<'a>(
..Default::default()
}));
let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle {
button::custom(badge)
.padding(0)
.name(if enabled { "Turn off" } else { "Turn on" })
.class(button_style(badge_color, false, opacity))
.on_press(cosmic::Action::App(Message::Control(action)))
.into()
motion::button(
button::custom(badge)
.padding(0)
.name(if enabled { "Turn off" } else { "Turn on" })
.on_press(cosmic::Action::App(Message::Control(action))),
badge_color,
false,
opacity,
)
} else {
badge.into()
};
@@ -800,13 +832,15 @@ fn connection_control<'a>(
.padding(4)
.into();
};
container(
container(motion::button(
button::custom(content)
.width(Length::FillPortion(1))
.padding(4)
.class(button_style(foreground, false, opacity))
.on_press(cosmic::Action::App(Message::Control(action))),
)
foreground,
false,
opacity,
))
.id("ferese-blur-card")
.width(Length::FillPortion(1))
.into()
@@ -818,11 +852,14 @@ fn menu_button<'a>(
foreground: Color,
opacity: f32,
) -> Element<'a, cosmic::Action<Message>> {
button::custom(text(label).size(13))
.padding([6, 8])
.class(button_style(foreground, false, opacity))
.on_press(cosmic::Action::App(message))
.into()
motion::button(
button::custom(text(label).size(13))
.padding([6, 8])
.on_press(cosmic::Action::App(message)),
foreground,
false,
opacity,
)
}
fn toggle_row<'a>(
label: &'a str,
@@ -843,24 +880,6 @@ fn toggle_row<'a>(
.align_y(Alignment::Center)
.into()
}
pub(super) fn button_style(foreground: Color, selected: bool, opacity: f32) -> theme::Button {
let style = move |strength: f32| button::Style {
text_color: Some(foreground),
icon_color: Some(foreground),
border_radius: 6.0.into(),
background: Some(Background::Color(Color {
a: strength * opacity,
..foreground
})),
..Default::default()
};
theme::Button::Custom {
active: Box::new(move |_, _| style(if selected { 0.14 } else { 0.0 })),
hovered: Box::new(move |_, _| style(if selected { 0.16 } else { 0.08 })),
pressed: Box::new(move |_, _| style(0.20)),
disabled: Box::new(move |_| style(0.0)),
}
}
fn slider_row(
source: &'static [u8],
@@ -1071,10 +1090,12 @@ mod tests {
#[test]
fn completed_open_stops_requesting_animation_ticks() {
let past = Instant::now() - Duration::from_secs(1);
let mut motion = super::motion::PopupMotion::new(Default::default());
motion.begin(past);
let menu = OpenMenu {
id: window::Id::unique(),
kind: Menu::Network,
opened: past,
motion,
confirm: None,
effects: None,
regions: Default::default(),
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "ferese-lock-probe"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
wayland-client.workspace = true
wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
+109
View File
@@ -0,0 +1,109 @@
//! Deliberately has no password authentication. Nested test sessions only.
use std::{error::Error, time::Duration};
use wayland_client::{Connection, Dispatch, QueueHandle, protocol::wl_registry};
use wayland_protocols::ext::session_lock::v1::client::{
ext_session_lock_manager_v1::ExtSessionLockManagerV1,
ext_session_lock_v1::{self, ExtSessionLockV1},
};
#[derive(Default)]
struct Probe {
manager: Option<ExtSessionLockManagerV1>,
locked: bool,
rejected: bool,
}
impl Dispatch<wl_registry::WlRegistry, ()> for Probe {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name, interface, ..
} = event
{
if interface == "ext_session_lock_manager_v1" {
state.manager = Some(registry.bind(name, 1, qh, ()));
}
}
}
}
wayland_client::delegate_noop!(Probe: ignore ExtSessionLockManagerV1);
impl Dispatch<ExtSessionLockV1, bool> for Probe {
fn event(
state: &mut Self,
_: &ExtSessionLockV1,
event: ext_session_lock_v1::Event,
competing: &bool,
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
ext_session_lock_v1::Event::Locked if !competing => state.locked = true,
ext_session_lock_v1::Event::Finished if *competing => state.rejected = true,
ext_session_lock_v1::Event::Finished => panic!("lock rejected"),
_ => {}
}
}
}
fn main() -> Result<(), Box<dyn Error>> {
if std::env::var_os("FERESE_LOCK_TEST").is_none() {
return Err(
"set FERESE_LOCK_TEST=1; this probe is only for disposable nested sessions".into(),
);
}
let mode = std::env::args().nth(1).unwrap_or_else(|| "unlock".into());
let connection = Connection::connect_to_env()?;
let mut queue = connection.new_event_queue();
let qh = queue.handle();
connection.display().get_registry(&qh, ());
let mut state = Probe::default();
queue.roundtrip(&mut state)?;
let manager = state
.manager
.clone()
.ok_or("session lock protocol unavailable")?;
let lock = manager.lock(&qh, false);
if mode == "invalid-unlock" {
lock.unlock_and_destroy();
assert!(
queue.roundtrip(&mut state).is_err(),
"premature unlock accepted"
);
println!("PASS premature unlock disconnected");
return Ok(());
}
while !state.locked {
queue.blocking_dispatch(&mut state)?;
}
println!("PASS locked event received after safe frame");
match mode.as_str() {
"crash" => return Ok(()),
"hold" => std::thread::sleep(Duration::from_secs(10)),
"competing" | "attack" => {
let other = manager.lock(&qh, true);
queue.roundtrip(&mut state)?;
assert!(state.rejected, "competing lock not rejected");
if mode == "attack" {
other.unlock_and_destroy();
assert!(
queue.roundtrip(&mut state).is_err(),
"rejected lock unlocked owner"
);
println!("PASS rejected owner cannot unlock");
return Ok(());
}
other.destroy();
}
"unlock" => {}
_ => return Err("use unlock, hold, crash, competing, attack, or invalid-unlock".into()),
}
lock.unlock_and_destroy();
queue.roundtrip(&mut state)?;
println!("PASS owner unlocked");
Ok(())
}