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", "fontdb",
"image", "image",
"libc", "libc",
"notify",
"serde", "serde",
"serde_json", "serde_json",
"smithay", "smithay",
@@ -1625,6 +1626,14 @@ dependencies = [
name = "ferese-layout" name = "ferese-layout"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "ferese-lock-probe"
version = "0.1.0"
dependencies = [
"wayland-client",
"wayland-protocols",
]
[[package]] [[package]]
name = "ferese-malformed-client" name = "ferese-malformed-client"
version = "0.1.0" version = "0.1.0"
@@ -1645,6 +1654,7 @@ name = "ferese-shell"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-protocols", "ferese-protocols",
"iced_accessibility",
"jiff", "jiff",
"libcosmic", "libcosmic",
"serde", "serde",
+1
View File
@@ -11,6 +11,7 @@ members = [
"tools/ferese-effects-probe", "tools/ferese-effects-probe",
"tools/ferese-malformed-client", "tools/ferese-malformed-client",
"tools/ferese-shell-control-probe", "tools/ferese-shell-control-probe",
"tools/ferese-lock-probe",
"tools/ferese-shell-spike", "tools/ferese-shell-spike",
] ]
resolver = "3" 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 [installation and recovery](docs/installation.md). `Super+Shift+E` or
`feresectl exit` logs out immediately; save your work first. `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 ## Development
Install the native dependencies required by Smithay's winit and GLES backends, 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. 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, It keeps window opacity intact, respects animated geometry and rounded corners,
and leaves shell bars/popovers untouched. Overview temporarily removes dimming. 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, All monitors share one elapsed-time animation clock, and window content,
clips, borders and shadows use the same physical-pixel edges. 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 Overview has a horizontally scrollable workspace strip with live window
thumbnails and an accent outline on the workspace shown by each output. thumbnails and an accent outline on the workspace shown by each output.
Click a workspace to change the window grid without leaving overview; click 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. `FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces.
When no valid image path is configured, the shell's existing fallback remains. 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 ```toml
[input] [input]
@@ -267,7 +344,8 @@ Unspecified connected outputs remain enabled with their preferred mode, scale
`1.0`, normal transform, and automatic horizontal placement. Supported `1.0`, normal transform, and automatic horizontal placement. Supported
transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`,
and their rotated flipped variants. Output configuration is applied at startup 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 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; 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` fields they specify. Width or height implies floating placement when `floating`
is omitted: 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 ```toml
[[window_rules]] [[window_rules]]
app_id = "org.example.Editor" 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-layout = { path = "../crates/ferese-layout" }
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] } ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
libc.workspace = true libc.workspace = true
notify = "8.2.0"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
smithay.workspace = true smithay.workspace = true
serde.workspace = true serde.workspace = true
+27 -1
View File
@@ -134,6 +134,7 @@ struct DirectOutput {
damage_tracker: OutputDamageTracker, damage_tracker: OutputDamageTracker,
render_metrics: RenderMetrics, render_metrics: RenderMetrics,
frame_pending: bool, frame_pending: bool,
lock_frame_pending: bool,
} }
struct OutputSelection { struct OutputSelection {
@@ -369,13 +370,17 @@ fn open_primary_device(
.insert_source(notifier, move |event, metadata, state| match event { .insert_source(notifier, move |event, metadata, state| match event {
DrmEvent::VBlank(crtc) => { DrmEvent::VBlank(crtc) => {
if let Some(metadata) = metadata { if let Some(metadata) = metadata {
let mut retired = false;
let feedback = state let feedback = state
.direct_backend .direct_backend
.as_mut() .as_mut()
.and_then(|backend| backend.devices.get_mut(&node)) .and_then(|backend| backend.devices.get_mut(&node))
.and_then(|device| device.outputs.get_mut(&crtc)) .and_then(|device| device.outputs.get_mut(&crtc))
.and_then(|output| match output.surface.frame_submitted() { .and_then(|output| match output.surface.frame_submitted() {
Ok(feedback) => feedback, Ok(feedback) => {
retired = true;
feedback
}
Err(error) => { Err(error) => {
tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame"); tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame");
None None
@@ -397,6 +402,21 @@ fn open_primary_device(
.and_then(|backend| backend.devices.get(&node)) .and_then(|backend| backend.devices.get(&node))
.and_then(|device| device.outputs.get(&crtc)) .and_then(|device| device.outputs.get(&crtc))
.is_some_and(|output| state.is_focused_output(&output.output)); .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 if let Some(output) = state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -632,6 +652,7 @@ fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
effects, effects,
); );
output.frame_pending = true; output.frame_pending = true;
output.lock_frame_pending = state.session_lock.active;
Ok(true) 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) { 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| { state.space.elements().for_each(|window| {
window.send_frame( window.send_frame(
output, output,
@@ -1099,6 +1124,7 @@ fn create_direct_output(
damage_tracker, damage_tracker,
render_metrics, render_metrics,
frame_pending: false, 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)] #[derive(Debug, Default, Deserialize)]
pub struct Config { pub struct Config {
#[serde(default)]
pub(crate) autostart: Vec<DaemonConfig>,
#[serde(default)] #[serde(default)]
animations: AnimationsConfig, animations: AnimationsConfig,
#[serde(default)] #[serde(default)]
@@ -36,6 +38,26 @@ pub struct Config {
scrolling: ScrollingConfig, scrolling: ScrollingConfig,
#[serde(default)] #[serde(default)]
output_profiles: Vec<OutputProfileConfig>, 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)] #[derive(Clone, Debug, PartialEq)]
@@ -105,12 +127,62 @@ struct OutputConfig {
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct RgbaColor(pub [f32; 4]); 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)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThemeSettings { pub struct ThemeSettings {
pub border_width: f64, pub border_width: f64,
pub focus_ring_width: f64, pub focus_ring_width: f64,
pub border_color: RgbaColor, pub border_color: RgbaColor,
pub accent_color: RgbaColor, pub accent_color: RgbaColor,
pub border_gradient: Option<BorderGradient>,
pub focus_ring_gradient: Option<BorderGradient>,
pub shadow_color: RgbaColor, pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor, pub surface_base_color: RgbaColor,
pub panel_opacity: f64, pub panel_opacity: f64,
@@ -167,6 +239,10 @@ impl Default for InactiveDimConfig {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ThemeConfig { struct ThemeConfig {
#[serde(default)]
border: BorderPaintConfig,
#[serde(default)]
focus_ring: BorderPaintConfig,
#[serde(default)] #[serde(default)]
typography: OverviewTypographyConfig, typography: OverviewTypographyConfig,
#[serde(default)] #[serde(default)]
@@ -184,6 +260,46 @@ struct ThemeConfig {
} }
impl Config { 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 { pub(crate) fn overview_font_family(&self) -> String {
self.theme self.theme
.typography .typography
@@ -905,6 +1021,8 @@ impl Config {
focus_ring_width, focus_ring_width,
border_color: parse_color(&self.theme.colors.border, "colors.border")?, border_color: parse_color(&self.theme.colors.border, "colors.border")?,
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?, 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")?, shadow_color: parse_color(&self.theme.colors.shadow, "colors.shadow")?,
surface_base_color: parse_color( surface_base_color: parse_color(
&self.theme.colors.surface_base, &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()) { if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(directory).join("ferese/config.toml")); return Some(PathBuf::from(directory).join("ferese/config.toml"));
} }
@@ -1724,6 +1842,8 @@ mod tests {
focus_ring_width: 3.0, focus_ring_width: 3.0,
border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.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]), 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]), 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,]), surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
panel_opacity: 0.78, panel_opacity: 0.78,
@@ -1757,6 +1877,45 @@ mod tests {
assert!(toml::from_str::<Config>("[theme.material]\nstyle = \"glass\"").is_err()); 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] #[test]
fn material_color_comes_from_surface_base() { fn material_color_comes_from_surface_base() {
let theme = parse("[theme.colors]\nsurface_base = \"#000000\"\n[theme.surface.bar]\nbackground = \"#FFFFFF\"") 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 = self.start + (target - self.start) * eased;
self.current != target 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn only_unfocused_windows_dim_and_overview_disables_it() { fn only_unfocused_windows_dim_and_overview_disables_it() {
+74 -3
View File
@@ -65,6 +65,9 @@ struct SurfaceEffectsState {
generation: AtomicU64, generation: AtomicU64,
role: Mutex<Option<SemanticRole>>, role: Mutex<Option<SemanticRole>>,
regions: Mutex<Option<Vec<[i32; 5]>>>, regions: Mutex<Option<Vec<[i32; 5]>>>,
opacity: Mutex<u32>,
presentation_supported: AtomicBool,
dismissing: AtomicBool,
} }
impl SurfaceEffectsState { impl SurfaceEffectsState {
@@ -74,6 +77,9 @@ impl SurfaceEffectsState {
generation: AtomicU64::new(0), generation: AtomicU64::new(0),
role: Mutex::new(None), role: Mutex::new(None),
regions: 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) { 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]>> { 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", "wl_surface already has a Ferese effects object",
); );
} else { } 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 => {} ferese_effects_manager_v1::Request::Destroy => {}
@@ -221,6 +235,27 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
}; };
match request { 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 } => { ferese_surface_effects_v1::Request::SetRole { role } => {
let role = match role { let role = match role {
WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel, WEnum::Value(ferese_surface_effects_v1::Role::Panel) => SemanticRole::Panel,
@@ -301,11 +336,47 @@ fn detach(surface: &WlSurface) -> bool {
return false; return false;
}; };
let role_changed = effects.set_role(None); 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); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+3
View File
@@ -27,6 +27,9 @@ impl XdgActivationHandler for Ferese {
data: XdgActivationTokenData, data: XdgActivationTokenData,
surface: WlSurface, surface: WlSurface,
) { ) {
if self.session_lock.active {
return;
}
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE; let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
self.xdg_activation_state.remove_token(&token); self.xdg_activation_state.remove_token(&token);
if !valid { if !valid {
+6
View File
@@ -90,6 +90,12 @@ impl CompositorHandler for Ferese {
} }
fn destroyed(&mut self, surface: &WlSurface) { 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); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
if self.idle_inhibitors.remove(surface).is_some() { 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(); 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) && matches!(policy.layer, Layer::Top | Layer::Overlay)
{ {
state state
+4
View File
@@ -137,6 +137,10 @@ impl Ferese {
with_damage: bool, with_damage: bool,
) { ) {
let mut used = data.used.lock().unwrap(); let mut used = data.used.lock().unwrap();
if self.session_lock.active {
frame.failed();
return;
}
if *used { if *used {
frame.post_error( frame.post_error(
zwlr_screencopy_frame_v1::Error::AlreadyUsed, 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) { 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 { let Some(seat) = Seat::from_resource(&seat) else {
return; return;
}; };
@@ -163,6 +166,9 @@ impl XdgShellHandler for Ferese {
serial: Serial, serial: Serial,
edges: smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge, 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 { let Some(seat) = Seat::from_resource(&seat) else {
return; return;
}; };
@@ -247,6 +253,9 @@ impl XdgShellHandler for Ferese {
} }
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) { 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 { let Some(seat) = Seat::from_resource(&seat) else {
return; return;
}; };
+57
View File
@@ -1,3 +1,4 @@
use smithay::reexports::wayland_server::Resource;
use std::process::Command; use std::process::Command;
use smithay::{ use smithay::{
@@ -26,6 +27,21 @@ impl Ferese {
let seat = self.seat.clone(); let seat = self.seat.clone();
self.idle_notifier_state.notify_activity(&seat); 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 { match event {
InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => { InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => {
crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On); crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On);
@@ -42,6 +58,9 @@ impl Ferese {
SERIAL_COUNTER.next_serial(), SERIAL_COUNTER.next_serial(),
Event::time(&event) as u32, Event::time(&event) as u32,
|data, modifiers, keysym| { |data, modifiers, keysym| {
if data.session_lock.active {
return FilterResult::Forward;
}
if state == KeyState::Released && data.intercepted_keys.remove(&keycode) { if state == KeyState::Released && data.intercepted_keys.remove(&keycode) {
return FilterResult::Intercept(()); return FilterResult::Intercept(());
} }
@@ -178,6 +197,30 @@ impl Ferese {
let pointer = self.seat.get_pointer().expect("seat has a pointer"); let pointer = self.seat.get_pointer().expect("seat has a pointer");
let serial = SERIAL_COUNTER.next_serial(); 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() { if self.overview.is_active() {
let position = pointer.current_location(); let position = pointer.current_location();
if self.layer_under(position).is_some() { 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) { 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"); let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
self.focus_output_at(position); self.focus_output_at(position);
@@ -390,6 +438,9 @@ impl Ferese {
pointer: &PointerHandle<Self>, pointer: &PointerHandle<Self>,
position: Point<f64, Logical>, position: Point<f64, Logical>,
) { ) {
if self.session_lock.active {
return;
}
if self.overview.is_active() if self.overview.is_active()
|| !self.input_settings.focus_follows_mouse || !self.input_settings.focus_follows_mouse
|| pointer.is_grabbed() || pointer.is_grabbed()
@@ -436,6 +487,9 @@ impl Ferese {
pointer: &PointerHandle<Self>, pointer: &PointerHandle<Self>,
requested: Point<f64, Logical>, requested: Point<f64, Logical>,
) -> Point<f64, Logical> { ) -> Point<f64, Logical> {
if self.session_lock.active {
return requested;
}
let current = pointer.current_location(); let current = pointer.current_location();
let Some(focused_surface) = pointer.current_focus() else { let Some(focused_surface) = pointer.current_focus() else {
return requested; return requested;
@@ -473,6 +527,9 @@ impl Ferese {
} }
pub fn activate_focused_pointer_constraint(&self, pointer: &PointerHandle<Self>) { pub fn activate_focused_pointer_constraint(&self, pointer: &PointerHandle<Self>) {
if self.session_lock.active {
return;
}
let position = pointer.current_location(); let position = pointer.current_location();
let Some((surface, origin)) = self.surface_under(position) else { let Some((surface, origin)) = self.surface_under(position) else {
return; return;
+10
View File
@@ -209,6 +209,13 @@ fn serve_connection(
impl Ferese { impl Ferese {
fn handle_ipc_request(&mut self, request: Request) -> Response { 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) { if let Err(error) = validate_request(&request) {
return Response::error(request.id, error.code, error.message); 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> { fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> {
match command { match command {
"exit" => {} // The IPC worker stops the loop after writing the response. "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)?), "focus" => self.focus_direction(direction_arg(args)?),
"move" => self.move_direction(direction_arg(args)?), "move" => self.move_direction(direction_arg(args)?),
"resize" => self.resize_direction(direction_arg(args)?), "resize" => self.resize_direction(direction_arg(args)?),
+56 -26
View File
@@ -3,6 +3,7 @@
mod backends; mod backends;
mod config; mod config;
mod cursor; mod cursor;
mod daemon;
mod dimming; mod dimming;
mod effects; mod effects;
mod grabs; mod grabs;
@@ -13,7 +14,9 @@ mod metrics;
mod overview; mod overview;
mod presentation; mod presentation;
mod private_client; mod private_client;
mod reload;
mod resize_transaction; mod resize_transaction;
mod session_lock;
mod shell_control; mod shell_control;
mod stacking; mod stacking;
mod state; mod state;
@@ -42,11 +45,11 @@ fn main() -> Result<(), Box<dyn Error>> {
let launch = LaunchConfig::from_environment() let launch = LaunchConfig::from_environment()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let config = Config::load()?; let initial_source = config::config_path().and_then(|path| std::fs::read_to_string(path).ok());
let input_settings = config.input_settings()?; let config = match initial_source.as_deref() {
let bindings = config.bindings(&input_settings)?; Some(source) => Config::parse_source(source)?,
let window_rules = config.window_rules()?; None => Config::load()?,
let output_profiles = config.output_profiles()?; };
let mut event_loop = EventLoop::try_new()?; let mut event_loop = EventLoop::try_new()?;
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?; let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
event_loop event_loop
@@ -56,29 +59,55 @@ fn main() -> Result<(), Box<dyn Error>> {
state.loop_signal.stop(); state.loop_signal.stop();
})?; })?;
let display = Display::new()?; let display = Display::new()?;
let runtime = RuntimeConfig { let runtime = config.runtime_config()?;
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 mut state = Ferese::new(&mut event_loop, display, runtime)?; 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)?; 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"); 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 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| { let result = event_loop.run(None, &mut state, |state| {
// All input/Wayland callbacks have returned, releasing seat locks. // All input/Wayland callbacks have returned, releasing seat locks.
// Coalesce cursor changes and redraw here, never inside cursor_image. // 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 { if let Some(child) = &mut child {
terminate_child(child); terminate_child(child);
} }
@@ -122,9 +152,9 @@ fn spawn_client(
// Wallpaper decoding and drivers may already have worker threads. Set // Wallpaper decoding and drivers may already have worker threads. Set
// the child's display explicitly instead of mutating the process env. // the child's display explicitly instead of mutating the process env.
command.env("WAYLAND_DISPLAY", &state.socket_name); command.env("WAYLAND_DISPLAY", &state.socket_name);
if state.wallpaper.owns_background() { command.env_remove("WAYLAND_SOCKET");
command.env("FERESE_COMPOSITOR_WALLPAPER", "1"); command.env_remove("FERESE_SHELL_CONTROL_SOCKET");
} command.env("FERESE_COMPOSITOR_WALLPAPER", "1");
let private_connection = if capabilities.is_empty() { let private_connection = if capabilities.is_empty() {
None None
} else { } else {
+8
View File
@@ -103,6 +103,14 @@ fn centered_row_start(strip: Rect, count: usize, card_width: f64) -> f64 {
} }
impl OverviewState { 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 { pub(crate) fn with_font_family(font_family: String) -> Self {
Self { Self {
font_family, 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}; use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
pub(crate) fn init_global(display: &DisplayHandle) { 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 { impl GlobalDispatch<FereseShellManagerV1, ()> for Ferese {
@@ -53,6 +74,11 @@ impl Dispatch<FereseShellManagerV1, ()> for Ferese {
let shell = data_init.init(id, ()); let shell = data_init.init(id, ());
shell.capabilities(1); 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())); shell.overview_state(u32::from(state.overview.is_active()));
state.send_shell_snapshot(&shell); state.send_shell_snapshot(&shell);
state.shell_resources.push(shell.downgrade()); state.shell_resources.push(shell.downgrade());
@@ -73,6 +99,9 @@ impl Dispatch<FereseShellV1, ()> for Ferese {
_display: &DisplayHandle, _display: &DisplayHandle,
_data_init: &mut DataInit<'_, Self>, _data_init: &mut DataInit<'_, Self>,
) { ) {
if state.session_lock.active {
return;
}
match request { match request {
ferese_shell_v1::Request::ActivateWindow { ferese_shell_v1::Request::ActivateWindow {
window_hi, window_hi,
@@ -371,6 +400,19 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe
mod tests { mod tests {
use super::*; 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] #[test]
fn window_ids_round_trip_through_protocol_words() { fn window_ids_round_trip_through_protocol_words() {
for id in [0, 1, u32::MAX as u64, u64::from(u32::MAX) + 1, u64::MAX] { 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; 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. /// Persistent back-to-front order. Geometry updates never change this order.
#[derive(Default)] #[derive(Default)]
pub(crate) struct WindowStack { pub(crate) struct WindowStack {
@@ -34,6 +46,13 @@ impl WindowStack {
mod tests { mod tests {
use super::*; 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] #[test]
fn remapping_neighbours_does_not_change_order() { fn remapping_neighbours_does_not_change_order() {
let mut stack = WindowStack::default(); let mut stack = WindowStack::default();
+215 -10
View File
@@ -107,6 +107,9 @@ impl ClosingAnimation {
} }
pub struct Ferese { 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 start_time: Instant,
pub socket_name: OsString, pub socket_name: OsString,
pub display_handle: DisplayHandle, pub display_handle: DisplayHandle,
@@ -125,10 +128,12 @@ pub struct Ferese {
maximized_windows: HashSet<WindowId>, maximized_windows: HashSet<WindowId>,
maximized_column_widths: HashMap<WindowId, ColumnWidth>, maximized_column_widths: HashMap<WindowId, ColumnWidth>,
window_stack: crate::stacking::WindowStack, window_stack: crate::stacking::WindowStack,
natural_floating_pending: HashSet<WindowId>,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>, pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dims: 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_resize_fills: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dimming: HashMap<WindowId, crate::dimming::DimAnimation>, 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) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>, pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>, pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
@@ -178,6 +183,11 @@ pub struct Ferese {
next_output_id: u64, next_output_id: u64,
last_animation_tick: Instant, last_animation_tick: Instant,
pub popups: PopupManager, pub popups: PopupManager,
pub(crate) dismissing_popups: Vec<(
WlSurface,
smithay::desktop::PopupKind,
crate::dimming::DimAnimation,
)>,
pub seat: Seat<Self>, pub seat: Seat<Self>,
pub alpha_modifier_state: AlphaModifierState, pub alpha_modifier_state: AlphaModifierState,
pub compositor_state: CompositorState, pub compositor_state: CompositorState,
@@ -227,6 +237,65 @@ pub struct RuntimeConfig {
} }
impl Ferese { 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) { pub fn register_output(&mut self, output: &Output, identity: String) {
let Some(geometry) = self.space.output_geometry(output) else { let Some(geometry) = self.space.output_geometry(output) else {
tracing::error!(output = %output.name(), "cannot register an unmapped output"); tracing::error!(output = %output.name(), "cannot register an unmapped output");
@@ -286,6 +355,8 @@ impl Ferese {
} }
}); });
self.space.unmap_output(output); self.space.unmap_output(output);
self.session_lock.surfaces.remove(output);
self.session_lock.backgrounds.remove(output);
match self.output_workspaces.disconnect(output_id) { match self.output_workspaces.disconnect(output_id) {
Ok(Some(target)) => { Ok(Some(target)) => {
@@ -406,6 +477,10 @@ impl Ferese {
let fractional_scale_state = FractionalScaleManagerState::new::<Self>(&display_handle); let fractional_scale_state = FractionalScaleManagerState::new::<Self>(&display_handle);
let idle_inhibit_state = IdleInhibitManagerState::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 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 = let input_method_enabled =
std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1"); std::env::var_os("FERESE_ENABLE_INPUT_METHOD").is_some_and(|value| value == "1");
let input_method_manager_state = let input_method_manager_state =
@@ -456,6 +531,9 @@ impl Ferese {
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]); let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
let mut state = Self { let mut state = Self {
session_lock_state,
session_lock: crate::session_lock::Lock::default(),
config_source: None,
start_time, start_time,
socket_name, socket_name,
display_handle, display_handle,
@@ -478,10 +556,12 @@ impl Ferese {
maximized_windows: HashSet::new(), maximized_windows: HashSet::new(),
maximized_column_widths: HashMap::new(), maximized_column_widths: HashMap::new(),
window_stack: crate::stacking::WindowStack::default(), window_stack: crate::stacking::WindowStack::default(),
natural_floating_pending: HashSet::new(),
window_borders: HashMap::new(), window_borders: HashMap::new(),
window_dims: HashMap::new(), window_dims: HashMap::new(),
window_resize_fills: HashMap::new(), window_resize_fills: HashMap::new(),
window_dimming: HashMap::new(), window_dimming: HashMap::new(),
window_focus: HashMap::new(),
window_shadows: HashMap::new(), window_shadows: HashMap::new(),
rounded_clip_programs: HashMap::new(), rounded_clip_programs: HashMap::new(),
overview_scrims: HashMap::new(), overview_scrims: HashMap::new(),
@@ -525,6 +605,7 @@ impl Ferese {
next_output_id: 1, next_output_id: 1,
last_animation_tick: start_time, last_animation_tick: start_time,
popups: PopupManager::default(), popups: PopupManager::default(),
dismissing_popups: Vec::new(),
seat, seat,
alpha_modifier_state, alpha_modifier_state,
compositor_state, compositor_state,
@@ -588,6 +669,9 @@ impl Ferese {
&self, &self,
position: Point<f64, Logical>, position: Point<f64, Logical>,
) -> Option<(WlSurface, 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]) self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
.map(|(_, surface, origin)| (surface, origin)) .map(|(_, surface, origin)| (surface, origin))
.or_else(|| self.window_surface_under(position)) .or_else(|| self.window_surface_under(position))
@@ -601,6 +685,9 @@ impl Ferese {
&self, &self,
position: Point<f64, Logical>, position: Point<f64, Logical>,
) -> Option<(LayerSurface, WlSurface, 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]) { if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) {
return Some(layer); return Some(layer);
} }
@@ -739,9 +826,18 @@ impl Ferese {
let mut windows = self.space.elements().cloned().collect::<Vec<_>>(); let mut windows = self.space.elements().cloned().collect::<Vec<_>>();
windows.sort_by_key(|window| { windows.sort_by_key(|window| {
let id = self.window_ids.get(window).copied(); let id = self.window_ids.get(window).copied();
let priority = id let geometry = id.and_then(|id| self.window_geometry.get(&id));
.and_then(|id| self.window_geometry.get(&id)) let floating = id.is_some_and(|id| {
.is_some_and(|geometry| geometry.is_fullscreen() || geometry.is_zooming()); 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, priority,
id.map_or(usize::MAX, |id| self.window_stack.rank(id)), id.map_or(usize::MAX, |id| self.window_stack.rank(id)),
@@ -891,6 +987,13 @@ impl Ferese {
Some(WindowPlacement::Floating { .. }) Some(WindowPlacement::Floating { .. })
); );
let mut rect = centered_floating_rect(bounds); 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.width = rule.width.unwrap_or(rect.width).min(bounds.width);
rect.height = rule.height.unwrap_or(rect.height).min(bounds.height); rect.height = rule.height.unwrap_or(rect.height).min(bounds.height);
rect.x = bounds.x + (bounds.width - rect.width) / 2.0; rect.x = bounds.x + (bounds.width - rect.width) / 2.0;
@@ -974,9 +1077,11 @@ impl Ferese {
self.maximized_windows.remove(&id); self.maximized_windows.remove(&id);
self.maximized_column_widths.remove(&id); self.maximized_column_widths.remove(&id);
self.window_stack.remove(id); self.window_stack.remove(id);
self.natural_floating_pending.remove(&id);
self.window_borders.remove(&id); self.window_borders.remove(&id);
self.window_dims.remove(&id); self.window_dims.remove(&id);
self.window_dimming.remove(&id); self.window_dimming.remove(&id);
self.window_focus.remove(&id);
self.window_shadows.remove(&id); self.window_shadows.remove(&id);
self.closing_windows.remove(&id); self.closing_windows.remove(&id);
self.window_rules_applied.remove(&id); self.window_rules_applied.remove(&id);
@@ -994,6 +1099,9 @@ impl Ferese {
} }
pub fn relayout(&mut self) { 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 // Consume idle time before setting new targets; it must not become a
// large first animation step after a keypress on an idle desktop. // large first animation step after a keypress on an idle desktop.
self.advance_animations(Instant::now()); self.advance_animations(Instant::now());
@@ -1214,12 +1322,21 @@ impl Ferese {
} }
} }
let visual = geometry.visual.current; 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); let location = (visual.x.round() as i32, visual.y.round() as i32);
self.space.map_element(window.clone(), location, false); self.space.map_element(window.clone(), location, false);
if let Some(toplevel) = window.toplevel() { if let Some(toplevel) = window.toplevel() {
let state_changed = toplevel.with_pending_state(|state| { 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()); state.size = Some((size.width, size.height).into());
} }
@@ -1258,7 +1375,7 @@ impl Ferese {
fullscreen_changed || maximized_changed || tiled_changed || decoration_changed 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() if let Some(serial) = toplevel.send_pending_configure()
&& requested_size.is_some() && requested_size.is_some()
&& had_geometry && had_geometry
@@ -1327,6 +1444,19 @@ impl Ferese {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut active_animation = false; 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 dim_settings = self.theme_settings.inactive_dim;
let duration = if self.animations_enabled { let duration = if self.animations_enabled {
@@ -1335,6 +1465,22 @@ impl Ferese {
0.0 0.0
}; };
let mut dim_changed = false; 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 { for (_, id) in &windows {
let target = crate::dimming::target( let target = crate::dimming::target(
dim_settings, dim_settings,
@@ -1347,7 +1493,7 @@ impl Ferese {
.entry(*id) .entry(*id)
.or_insert_with(|| crate::dimming::DimAnimation::new(target)); .or_insert_with(|| crate::dimming::DimAnimation::new(target));
let previous = dim.current; let previous = dim.current;
active_animation |= dim.advance(target, delta, duration); active_animation |= dim.advance_visual(target, delta, duration);
dim_changed |= previous != dim.current; dim_changed |= previous != dim.current;
} }
@@ -1504,6 +1650,7 @@ impl Ferese {
); );
} }
if let Some(size) = geometry.presentation_size_request(self.start_time.elapsed()) if let Some(size) = geometry.presentation_size_request(self.start_time.elapsed())
&& !self.natural_floating_pending.contains(&id)
&& let Some(toplevel) = window.toplevel() && let Some(toplevel) = window.toplevel()
{ {
toplevel.with_pending_state(|state| { toplevel.with_pending_state(|state| {
@@ -1625,7 +1772,7 @@ impl Ferese {
} }
pub fn record_client_commit(&mut self, window: &Window) { 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; return;
}; };
// current_serial is promoted by Smithay on commit, unlike // current_serial is promoted by Smithay on commit, unlike
@@ -1640,15 +1787,32 @@ impl Ferese {
}); });
if self if self
.resize_transactions .resize_transactions
.get(id) .get(&id)
.is_some_and(|transaction| transaction.accepts(committed_serial)) .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 { let Some(size) = client_size(window) else {
return; 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; return;
}; };
@@ -2204,6 +2368,10 @@ impl Ferese {
} }
pub(crate) fn restore_keyboard_focus(&mut self) { 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| { let surface = self.focused_window.and_then(|focused| {
self.window_ids.iter().find_map(|(window, id)| { self.window_ids.iter().find_map(|(window, id)| {
(*id == focused) (*id == focused)
@@ -2226,6 +2394,18 @@ impl Ferese {
self.output_bounds_for(output) 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> { pub(crate) fn output_bounds_for(&self, output: &Output) -> Option<Rect> {
let geometry = self.space.output_geometry(output)?; let geometry = self.space.output_geometry(output)?;
let zone = layer_map_for_output(output).non_exclusive_zone(); 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 { fn smoothstep(progress: f64) -> f64 {
let progress = progress.clamp(0.0, 1.0); let progress = progress.clamp(0.0, 1.0);
@@ -2394,6 +2585,20 @@ impl ClientData for ClientState {
mod tests { mod tests {
use super::*; 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] #[test]
fn maximization_respects_layer_exclusion_and_outer_gaps() { fn maximization_respects_layer_exclusion_and_outer_gaps() {
assert_eq!( assert_eq!(
+86 -2
View File
@@ -17,7 +17,7 @@ use smithay::{
}; };
use std::{collections::HashMap, path::PathBuf, sync::mpsc}; use std::{collections::HashMap, path::PathBuf, sync::mpsc};
#[derive(Clone, Debug, Default, Deserialize)] #[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub(crate) struct WallpaperConfig { pub(crate) struct WallpaperConfig {
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
#[serde(default)] #[serde(default)]
@@ -36,6 +36,9 @@ struct WallpaperTexture {
id: Id, id: Id,
} }
pub(crate) struct WallpaperState { pub(crate) struct WallpaperState {
config: WallpaperConfig,
pending: Option<WallpaperConfig>,
commit: CommitCounter,
owned: bool, owned: bool,
mode: WallpaperMode, mode: WallpaperMode,
receiver: Option<mpsc::Receiver<Result<image::RgbaImage, String>>>, receiver: Option<mpsc::Receiver<Result<image::RgbaImage, String>>>,
@@ -45,6 +48,7 @@ pub(crate) struct WallpaperState {
impl WallpaperState { impl WallpaperState {
pub fn new(config: WallpaperConfig) -> Self { pub fn new(config: WallpaperConfig) -> Self {
let retained = config.clone();
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
let owned = config.path.as_ref().is_some_and(|path| path.is_file()); let owned = config.path.as_ref().is_some_and(|path| path.is_file());
if owned { if owned {
@@ -64,6 +68,9 @@ impl WallpaperState {
}); });
} }
Self { Self {
config: retained,
pending: None,
commit: CommitCounter::default(),
owned, owned,
mode: config.mode, mode: config.mode,
receiver: owned.then_some(receiver), receiver: owned.then_some(receiver),
@@ -71,9 +78,38 @@ impl WallpaperState {
textures: HashMap::new(), textures: HashMap::new(),
} }
} }
#[cfg(test)]
pub fn owns_background(&self) -> bool { pub fn owns_background(&self) -> bool {
self.owned 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) { pub fn forget_context(&mut self, context: &ErasedContextId) {
self.textures.remove(context); self.textures.remove(context);
} }
@@ -84,6 +120,14 @@ impl WallpaperState {
match receiver.try_recv() { match receiver.try_recv() {
Ok(result) => { Ok(result) => {
self.receiver = None; 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 { match result {
Ok(pixels) => { Ok(pixels) => {
tracing::info!( tracing::info!(
@@ -93,6 +137,8 @@ impl WallpaperState {
"decoded compositor wallpaper once" "decoded compositor wallpaper once"
); );
self.pixels = Some(pixels); self.pixels = Some(pixels);
self.textures.clear();
self.commit.increment();
} }
Err(error) => { Err(error) => {
tracing::warn!(%error, "wallpaper unavailable; using output clear color") tracing::warn!(%error, "wallpaper unavailable; using output clear color")
@@ -147,7 +193,7 @@ impl WallpaperState {
); );
Some(NativeTextureElement { Some(NativeTextureElement {
id: cached.id.clone(), id: cached.id.clone(),
commit: CommitCounter::default(), commit: self.commit,
texture: cached.texture.clone(), texture: cached.texture.clone(),
geometry, geometry,
source, source,
@@ -204,6 +250,44 @@ fn image_geometry(
mod tests { mod tests {
use super::*; use super::*;
#[test] #[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() { fn fill_crops_and_fit_letterboxes_without_reallocating_image() {
let (geometry, source) = image_geometry( let (geometry, source) = image_geometry(
(3840, 2160).into(), (3840, 2160).into(),
+266 -23
View File
@@ -85,6 +85,7 @@ render_elements! {
Surface=SurfaceRenderElement, Surface=SurfaceRenderElement,
Memory=MemoryRenderElement, Memory=MemoryRenderElement,
Solid=SolidColorRenderElement, Solid=SolidColorRenderElement,
LockSurface=WaylandSurfaceRenderElement<GlesRenderer>,
Border=PixelShaderElement, Border=PixelShaderElement,
Blur=BlurRenderElement, Blur=BlurRenderElement,
Effect=PhysicalShaderElement, Effect=PhysicalShaderElement,
@@ -226,13 +227,19 @@ void main() {
"#; "#;
const ROUNDED_BORDER_SHADER: &str = r#" const ROUNDED_BORDER_SHADER: &str = r#"
precision mediump float; precision highp float;
uniform float alpha; uniform float alpha;
uniform vec4 clip_rect; uniform vec4 clip_rect;
uniform float radius; uniform float radius;
uniform float border_width; uniform float border_width;
uniform vec4 border_color; 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; varying vec2 v_coords;
#if defined(DEBUG_FLAGS) #if defined(DEBUG_FLAGS)
@@ -257,8 +264,15 @@ void main() {
- inner_radius; - inner_radius;
float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance); float inner_coverage = 1.0 - smoothstep(-0.5, 0.5, inner_signed_distance);
float coverage = max(outer_coverage - inner_coverage, 0.0); float coverage = max(outer_coverage - inner_coverage, 0.0);
vec4 color = vec4(border_color.rgb * border_color.a, border_color.a) float progress = clamp(dot(gl_FragCoord.xy - gradient_line.xy, gradient_line.zw), 0.0, 1.0);
* coverage * alpha; // 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 defined(DEBUG_FLAGS)
if (tint == 1.0) if (tint == 1.0)
@@ -357,13 +371,14 @@ uniform vec4 visible_rect;
uniform float material_radius; uniform float material_radius;
uniform vec2 texture_size; uniform vec2 texture_size;
uniform float blur_radius; uniform float blur_radius;
uniform float presentation_alpha;
uniform vec4 tint; uniform vec4 tint;
varying vec2 v_coords; varying vec2 v_coords;
void main() { void main() {
vec2 half_size = visible_rect.zw * 0.5; vec2 half_size = visible_rect.zw * 0.5;
vec2 d = abs(gl_FragCoord.xy - visible_rect.xy - half_size) - (half_size - vec2(material_radius)); 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 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; } if (coverage <= 0.0) { gl_FragColor = vec4(0.0); return; }
// Denser Gaussian sampling preserves the same radius and softness without // Denser Gaussian sampling preserves the same radius and softness without
// the visible grid left by widely spaced taps on detailed wallpapers. // the visible grid left by widely spaced taps on detailed wallpapers.
@@ -562,6 +577,7 @@ pub(crate) struct MaterialProgram(GlesPixelProgram);
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
struct MaterialParameters { struct MaterialParameters {
presentation_alpha: f32,
blur: f32, blur: f32,
scene_generation: u64, scene_generation: u64,
sample_geometry: Rectangle<i32, Logical>, sample_geometry: Rectangle<i32, Logical>,
@@ -604,6 +620,12 @@ struct BorderParameters {
radius: f32, radius: f32,
width: f32, width: f32,
color: [f32; 4], 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)] #[derive(Debug)]
@@ -902,6 +924,9 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
state.loop_signal.stop(); state.loop_signal.stop();
return; return;
} }
if state.session_lock.active {
state.lock_frame_presented(&output);
}
let elapsed = render_started.elapsed(); let elapsed = render_started.elapsed();
if elapsed > refresh.get() { if elapsed > refresh.get() {
missed_deadlines += (elapsed.as_nanos() / refresh.get().as_nanos()) as u64; 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) { 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(); let time = state.start_time.elapsed();
for window in state.space.elements() { for window in state.space.elements() {
window.send_frame(output, time, Some(refresh), |_, _| Some(output.clone())); window.send_frame(output, time, Some(refresh), |_, _| Some(output.clone()));
@@ -985,6 +1014,58 @@ fn output_elements(
output: &Output, output: &Output,
include_cursor: bool, include_cursor: bool,
) -> Vec<AnimatedWindowRenderElement> { ) -> 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() { if state.wallpaper.poll() {
state.backdrop_generation = state.backdrop_generation.wrapping_add(1); state.backdrop_generation = state.backdrop_generation.wrapping_add(1);
} }
@@ -1082,18 +1163,15 @@ fn output_elements(
} else { } else {
state.focused_window == Some(id) state.focused_window == Some(id)
}; };
let border_width = if focused { let focus = state
state.theme_settings.focus_ring_width .window_focus
} else { .get(&id)
state.theme_settings.border_width .map_or(if focused { 1.0 } else { 0.0 }, |v| v.current);
}; let border_width = state.theme_settings.border_width
let border_color = if focused { + (state.theme_settings.focus_ring_width - state.theme_settings.border_width)
state.theme_settings.accent_color.0 * focus;
} else { let border_color = state.theme_settings.border_color.0;
state.theme_settings.border_color.0 let gradient = state.theme_settings.border_gradient;
};
let border_color =
color_with_alpha(border_color, close_alpha * decoration_progress as f32);
if let Some(border) = window_border_element( if let Some(border) = window_border_element(
state, state,
@@ -1105,6 +1183,9 @@ fn output_elements(
window_radius, window_radius,
border_width, border_width,
border_color, border_color,
gradient,
focus as f32,
close_alpha * decoration_progress as f32,
output, output,
&programs, &programs,
) { ) {
@@ -1263,6 +1344,12 @@ fn overview_chrome_element(
radius: (radius * scale) as f32, radius: (radius * scale) as f32,
width: (width * scale) as f32, width: (width * scale) as f32,
color, 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 { let program = if key.1 {
rounded_clip_program(state, renderer)?.border rounded_clip_program(state, renderer)?.border
@@ -1472,6 +1559,12 @@ fn rounded_clip_program(
UniformName::new("radius", UniformType::_1f), UniformName::new("radius", UniformType::_1f),
UniformName::new("border_width", UniformType::_1f), UniformName::new("border_width", UniformType::_1f),
UniformName::new("border_color", UniformType::_4f), 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 = [ let shadow_uniforms = [
UniformName::new("shadow_rect", UniformType::_4f), UniformName::new("shadow_rect", UniformType::_4f),
@@ -1516,6 +1609,9 @@ fn window_border_element(
requested_radius: f64, requested_radius: f64,
requested_width: f64, requested_width: f64,
color: [f32; 4], color: [f32; 4],
gradient: Option<crate::config::BorderGradient>,
focus_mix: f32,
opacity: f32,
output: &Output, output: &Output,
programs: &RoundedClipPrograms, programs: &RoundedClipPrograms,
) -> Option<PhysicalShaderElement> { ) -> Option<PhysicalShaderElement> {
@@ -1525,12 +1621,49 @@ fn window_border_element(
return None; 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 { let parameters = BorderParameters {
geometry, geometry,
clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()), clip_rect: framebuffer_clip_rect(physical, mode.size, output.current_transform().invert()),
radius: scaled_effect_value(requested_radius, geometry, scale), radius: scaled_effect_value(requested_radius, geometry, scale),
width, 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 context = renderer.context_id().erased();
let buffers = state.window_borders.entry(id).or_default(); 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("radius", parameters.radius).into_owned(),
Uniform::new("border_width", parameters.width).into_owned(), Uniform::new("border_width", parameters.width).into_owned(),
Uniform::new("border_color", parameters.color).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), radius: scaled_effect_value(radius, geometry, scale),
width: 0.0, width: 0.0,
color, 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| { let uniforms = |p: &BorderParameters| {
vec![ 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>( pub(crate) fn redraw_output<R, E>(
renderer: &mut R, renderer: &mut R,
framebuffer: &mut R::Framebuffer<'_>, framebuffer: &mut R::Framebuffer<'_>,
@@ -2087,7 +2260,7 @@ fn append_material_surface(
surface, surface,
content_origin.to_physical_precise_round(scale), content_origin.to_physical_precise_round(scale),
scale, scale,
1.0, crate::effects::surface_opacity(surface),
RenderElementKind::Unspecified, RenderElementKind::Unspecified,
); );
elements.extend(content.into_iter().filter_map(|element| { elements.extend(content.into_iter().filter_map(|element| {
@@ -2125,6 +2298,7 @@ fn material_element(
) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> { ) -> Option<(AnimatedWindowRenderElement, AnimatedWindowRenderElement)> {
let (role, generation) = crate::effects::surface_role(surface)?; let (role, generation) = crate::effects::surface_role(surface)?;
let mut material = crate::effects::resolve_material(role, state.theme_settings.material_style); 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 if role == crate::effects::SemanticRole::Panel
&& material.style == crate::config::MaterialStyle::Translucent && material.style == crate::config::MaterialStyle::Translucent
{ {
@@ -2138,7 +2312,11 @@ fn material_element(
let [offset_y, shadow_blur, shadow_opacity] = material.shadow; let [offset_y, shadow_blur, shadow_opacity] = material.shadow;
let edge_bar = let edge_bar =
role == crate::effects::SemanticRole::Panel && radius == 0.0 && geometry.loc.y == 0; 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( let shadow_geometry = Rectangle::new(
(geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(), (geometry.loc.x, geometry.loc.y + offset_y.round() as i32).into(),
geometry.size, 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_geometry = expanded_blur_region(capture_geometry, blur.ceil() as i32, output_size);
let sample_physical = sample_geometry.to_physical_precise_round(scale); let sample_physical = sample_geometry.to_physical_precise_round(scale);
let parameters = MaterialParameters { let parameters = MaterialParameters {
presentation_alpha,
blur: (blur * scale) as f32, blur: (blur * scale) as f32,
scene_generation: if blur > 0.0 { scene_generation: if blur > 0.0 {
state.backdrop_generation state.backdrop_generation
@@ -2175,9 +2354,14 @@ fn material_element(
shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32], shadow_values: [(shadow_blur * scale) as f32, shadow_opacity as f32],
shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur), shadow_bounds: shadow_bounds(geometry, offset_y, shadow_blur),
geometry, geometry,
tint: [red, green, blue, material.opacity], tint: [
red,
green,
blue,
material.opacity * if blur > 0.0 { 1.0 } else { presentation_alpha },
],
generation, 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( visible_framebuffer: framebuffer_clip_rect(
geometry.to_physical_precise_round(scale), geometry.to_physical_precise_round(scale),
mode.size, 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("material_radius", UniformType::_1f),
UniformName::new("texture_size", UniformType::_2f), UniformName::new("texture_size", UniformType::_2f),
UniformName::new("blur_radius", UniformType::_1f), UniformName::new("blur_radius", UniformType::_1f),
UniformName::new("presentation_alpha", UniformType::_1f),
UniformName::new("tint", UniformType::_4f), UniformName::new("tint", UniformType::_4f),
]; ];
match renderer.compile_custom_texture_shader(BLUR_SHADER, &uniforms) { match renderer.compile_custom_texture_shader(BLUR_SHADER, &uniforms) {
@@ -2332,6 +2517,7 @@ fn blur_uniforms(p: &MaterialParameters) -> Vec<Uniform<'static>> {
) )
.into_owned(), .into_owned(),
Uniform::new("blur_radius", p.blur).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(), Uniform::new("tint", p.tint).into_owned(),
] ]
} }
@@ -2491,10 +2677,67 @@ mod tests {
}; };
use super::{ use super::{
color_with_alpha, framebuffer_clip_rect, normalized_scale, resize_content_behavior, border_gradient_line, color_with_alpha, framebuffer_clip_rect, normalized_scale,
rounded_visual_rect, scaled_visual_rect, shadow_bounds, 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)] #[derive(Debug)]
struct DamageElement { struct DamageElement {
id: Id, id: Id,
+45
View File
@@ -376,6 +376,23 @@ impl Default for WorkspaceSet {
} }
impl 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( pub fn new(
default_layout_mode: LayoutMode, default_layout_mode: LayoutMode,
default_column_width: ColumnWidth, default_column_width: ColumnWidth,
@@ -994,6 +1011,34 @@ fn finite_or_zero(value: f64) -> f64 {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
+3
View File
@@ -100,6 +100,9 @@ impl Default for ScrollingLayout {
} }
impl 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 { pub fn with_default_width(default_width: ColumnWidth) -> Self {
Self { Self {
default_width: normalized_width(default_width), 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-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout"
| "toggle-overview" | "cycle-column-width" | "center-column" | "consume" | "expel" | "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() { if !positional.is_empty() {
return Err(format!("{command} does not accept arguments")); return Err(format!("{command} does not accept arguments"));
} }
@@ -78,7 +79,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { 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)] #[cfg(test)]
@@ -122,6 +123,7 @@ mod tests {
); );
assert!(parse_args(["exit".to_owned(), "extra".to_owned()]).is_err()); assert!(parse_args(["exit".to_owned(), "extra".to_owned()]).is_err());
assert!(parse_args(["focus".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. DEALINGS IN THE SOFTWARE.
</copyright> </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"> <description summary="assign semantic material roles to surfaces">
This private global is visible only to clients granted the Ferese effects This private global is visible only to clients granted the Ferese effects
capability. Roles describe compositor-owned appearance policy and do not capability. Roles describe compositor-owned appearance policy and do not
@@ -48,7 +48,7 @@
</enum> </enum>
</interface> </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"> <description summary="semantic material role for one surface">
The role affects only compositor rendering. It does not change surface The role affects only compositor rendering. It does not change surface
geometry, input, focus, stacking, or the surface's Wayland role. geometry, input, focus, stacking, or the surface's Wayland role.
@@ -88,5 +88,12 @@
</description> </description>
<arg name="regions" type="array"/> <arg name="regions" type="array"/>
</request> </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> </interface>
</protocol> </protocol>
+13 -2
View File
@@ -22,7 +22,7 @@
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
</copyright> </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"> <description summary="create a capability-scoped Ferese shell controller">
This private global is visible only to clients granted the shell-control This private global is visible only to clients granted the shell-control
capability. It does not assign a Wayland surface role. capability. It does not assign a Wayland surface role.
@@ -35,7 +35,7 @@
<request name="destroy" type="destructor"/> <request name="destroy" type="destructor"/>
</interface> </interface>
<interface name="ferese_shell_v1" version="1"> <interface name="ferese_shell_v1" version="2">
<description summary="managed-window metadata and shell control"> <description summary="managed-window metadata and shell control">
Window, workspace, and output identifiers are unsigned 64-bit values Window, workspace, and output identifiers are unsigned 64-bit values
encoded as high and low 32-bit words. Each snapshot atomically replaces encoded as high and low 32-bit words. Each snapshot atomically replaces
@@ -132,5 +132,16 @@
<description summary="marks completion of initial protocol setup"/> <description summary="marks completion of initial protocol setup"/>
<arg name="version" type="uint"/> <arg name="version" type="uint"/>
</event> </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> </interface>
</protocol> </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; } [[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; }
# Refuse to overwrite unrelated installations. # 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 link=/usr/local/bin/$name
if [[ -e $link || -L $link ]]; then if [[ -e $link || -L $link ]]; then
[[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || { [[ -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" install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name"
done done
install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" 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" install -m 0644 -- "$repo_dir/packaging/config.toml" "$release_dir/config.example.toml"
if [[ -L $install_root/current ]]; then if [[ -L $install_root/current ]]; then
@@ -50,7 +51,7 @@ if [[ -L $install_root/current ]]; then
fi fi
ln -s -- "releases/$release_id" "$install_root/current-$release_id" ln -s -- "releases/$release_id" "$install_root/current-$release_id"
mv -Tf -- "$install_root/current-$release_id" "$install_root/current" 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" ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name"
done done
install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry" 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 # Keep GPU rendering available as a fallback/override. The shell defaults to
# tiny-skia to avoid a second GPU initialization during desktop startup. # tiny-skia to avoid a second GPU initialization during desktop startup.
libcosmic = { workspace = true, features = ["wgpu", "a11y"] } libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
serde.workspace = true serde.workspace = true
toml.workspace = true toml.workspace = true
wayland-client.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)] #[derive(Clone, Debug, Default)]
pub(crate) struct ShellConfig { pub(crate) struct ShellConfig {
pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>, pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig, pub(crate) wallpaper: WallpaperConfig,
pub(crate) theme: ShellTheme, pub(crate) theme: ShellTheme,
@@ -111,6 +112,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct FereseConfig { struct FereseConfig {
#[serde(default)]
animations: crate::motion::Settings,
#[serde(default)] #[serde(default)]
appearance: AppearanceConfig, appearance: AppearanceConfig,
#[serde(default)] #[serde(default)]
@@ -284,13 +287,24 @@ pub(crate) fn load() -> ShellConfig {
return ShellConfig::default(); 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) => { Ok(config) => {
let mut theme = shell_theme(&config.theme); let mut theme = shell_theme(&config.theme);
theme.material_radius = theme.material_radius =
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0); 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, font_family: config.theme.typography.font_family,
wallpaper: config.theme.background, wallpaper: config.theme.background,
theme, theme,
@@ -298,12 +312,9 @@ pub(crate) fn load() -> ShellConfig {
low_battery_threshold: config.status.low_battery_threshold.min(100), low_battery_threshold: config.status.low_battery_threshold.min(100),
..config.status ..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)] #[derive(Clone, Debug)]
pub(crate) struct WorkspaceSnapshot { pub(crate) struct WorkspaceSnapshot {
pub(crate) id: u64, pub(crate) id: u64,
pub(crate) name: String,
pub(crate) output: Option<u64>, pub(crate) output: Option<u64>,
pub(crate) active: bool, pub(crate) active: bool,
} }
@@ -58,12 +59,14 @@ pub(crate) struct ShellControl {
} }
pub(crate) struct ControlPoll { pub(crate) struct ControlPoll {
pub(crate) config: Option<String>,
pub(crate) snapshot: Option<ShellSnapshot>, pub(crate) snapshot: Option<ShellSnapshot>,
pub(crate) overview_active: Option<bool>, pub(crate) overview_active: Option<bool>,
pub(crate) disconnected: bool, pub(crate) disconnected: bool,
} }
enum ControlUpdate { enum ControlUpdate {
Config(String),
Snapshot(ShellSnapshot), Snapshot(ShellSnapshot),
OverviewState(bool), OverviewState(bool),
Disconnected, Disconnected,
@@ -74,7 +77,7 @@ impl ShellControl {
let connection = control_connection()?; let connection = control_connection()?;
let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?; let (globals, mut queue) = registry_queue_init::<ControlState>(&connection)?;
let qh = queue.handle(); 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 shell = manager.get_shell(&qh, ());
let (sender, updates) = mpsc::channel(); let (sender, updates) = mpsc::channel();
let mut state = ControlState::new(sender); let mut state = ControlState::new(sender);
@@ -97,6 +100,7 @@ impl ShellControl {
pub(crate) fn poll(&self) -> ControlPoll { pub(crate) fn poll(&self) -> ControlPoll {
let mut poll = ControlPoll { let mut poll = ControlPoll {
config: None,
snapshot: None, snapshot: None,
overview_active: None, overview_active: None,
disconnected: false, disconnected: false,
@@ -104,6 +108,7 @@ impl ShellControl {
loop { loop {
match self.updates.try_recv() { match self.updates.try_recv() {
Ok(ControlUpdate::Config(source)) => poll.config = Some(source),
Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot), Ok(ControlUpdate::Snapshot(snapshot)) => poll.snapshot = Some(snapshot),
Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active), Ok(ControlUpdate::OverviewState(active)) => poll.overview_active = Some(active),
Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => { Ok(ControlUpdate::Disconnected) | Err(TryRecvError::Disconnected) => {
@@ -156,6 +161,7 @@ fn control_connection() -> Result<Connection, Box<dyn Error>> {
} }
struct ControlState { struct ControlState {
config: Option<String>,
sender: Sender<ControlUpdate>, sender: Sender<ControlUpdate>,
pending: ShellSnapshot, pending: ShellSnapshot,
serial: Option<u32>, serial: Option<u32>,
@@ -165,6 +171,7 @@ impl ControlState {
fn new(sender: Sender<ControlUpdate>) -> Self { fn new(sender: Sender<ControlUpdate>) -> Self {
Self { Self {
sender, sender,
config: None,
pending: ShellSnapshot::default(), pending: ShellSnapshot::default(),
serial: None, serial: None,
} }
@@ -193,6 +200,15 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
_qh: &QueueHandle<Self>, _qh: &QueueHandle<Self>,
) { ) {
match event { 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 } => { ferese_shell_v1::Event::SnapshotBegin { serial } => {
state.serial = Some(serial); state.serial = Some(serial);
state.pending = ShellSnapshot::default(); state.pending = ShellSnapshot::default();
@@ -215,13 +231,14 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
workspace_lo, workspace_lo,
output_hi, output_hi,
output_lo, output_lo,
name: _, name,
active, active,
} => { } => {
let output = join_id(output_hi, output_lo); let output = join_id(output_hi, output_lo);
state.pending.workspaces.push(WorkspaceSnapshot { state.pending.workspaces.push(WorkspaceSnapshot {
id: join_id(workspace_hi, workspace_lo), id: join_id(workspace_hi, workspace_lo),
name,
output: (output != 0).then_some(output), output: (output != 0).then_some(output),
active: active != 0, active: active != 0,
}); });
@@ -276,3 +293,34 @@ fn split_id(id: u64) -> (u32, u32) {
fn join_id(hi: u32, lo: u32) -> u64 { fn join_id(hi: u32, lo: u32) -> u64 {
(u64::from(hi) << 32) | u64::from(lo) (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::{ use cosmic::iced::platform_specific::{
runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings}, runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings},
shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, 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::{ use cosmic::iced::{
Background, Border, Color, ContentFit, Event, Length, Limits, Subscription, window, 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"; 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 { fn configured_font(family: Option<&str>) -> cosmic::font::Font {
family.map_or_else(cosmic::font::default, |family| { 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()); 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>( fn text<'a>(
content: impl Into<std::borrow::Cow<'a, str>> + 'a, content: impl Into<std::borrow::Cow<'a, str>> + 'a,
) -> cosmic::widget::Text<'a, cosmic::Theme, cosmic::Renderer> { ) -> 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)] #[derive(Clone, Copy)]
@@ -66,11 +88,6 @@ struct BarMetrics {
icon_size: u16, icon_size: u16,
overview_icon_size: u16, overview_icon_size: u16,
control_height: f32, 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 { impl From<ShellTheme> for BarMetrics {
@@ -85,11 +102,6 @@ impl From<ShellTheme> for BarMetrics {
icon_size: 19, icon_size: 19,
overview_icon_size: 19, overview_icon_size: 19,
control_height: (height - 4.0).max(21.0).min(height), 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") }; unsafe { std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu") };
} }
let mut config = config::load(); let mut config = config::load();
motion::configure(config.animations);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
if compositor_wallpaper { if compositor_wallpaper {
// The compositor owns one GPU image, shared across output renderers; // The compositor owns one GPU image, shared across output renderers;
@@ -110,7 +123,7 @@ fn main() -> cosmic::iced::Result {
config.wallpaper.path = None; config.wallpaper.path = None;
} }
let shell_font = configured_font(config.font_family.as_deref()); 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. // Decode alongside toolkit/GPU initialization, never during a UI draw.
let wallpaper = config.wallpaper.path.clone().map(|path| { let wallpaper = config.wallpaper.path.clone().map(|path| {
let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel(); let (sender, receiver) = cosmic::iced::futures::channel::oneshot::channel();
@@ -173,7 +186,6 @@ enum Message {
), ),
Tick, Tick,
ActivateWorkspace(u64), ActivateWorkspace(u64),
ActivateWindow(u64),
ToggleOverview, ToggleOverview,
StatusTick, StatusTick,
AnimateMenu, 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(500)).map(|_| Message::Tick),
cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::StatusTick), 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) cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
} else { } else {
Subscription::none() Subscription::none()
@@ -264,6 +280,9 @@ impl cosmic::Application for FereseShell {
if let Some(menu) = &self.menu if let Some(menu) = &self.menu
&& let Some(effects) = &menu.effects && 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(); let regions = menu.regions.lock().unwrap().clone();
if let Err(error) = effects.set_regions(&regions) { if let Err(error) = effects.set_regions(&regions) {
eprintln!("ferese-shell: could not update card materials: {error}"); 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}") 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}"), Err(error) => eprintln!("ferese-shell: native surface unavailable: {error}"),
@@ -310,7 +332,7 @@ impl cosmic::Application for FereseShell {
} }
Task::none() Task::none()
} }
Message::AnimateMenu => Task::none(), Message::AnimateMenu => self.animate_menu(),
Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor), Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
Message::OpenMenuOn(id, kind, anchor) => { Message::OpenMenuOn(id, kind, anchor) => {
if self.bar_surface_id != id { if self.bar_surface_id != id {
@@ -343,13 +365,14 @@ impl cosmic::Application for FereseShell {
action, action,
status::Action::Notifications | status::Action::Settings status::Action::Notifications | status::Action::Settings
) { ) {
return self.destroy_menu(); return self.close_menu();
} }
Task::none() Task::none()
} }
Message::Event(event, id) => self.handle_event(event, id), Message::Event(event, id) => self.handle_event(event, id),
Message::Tick => { Message::Tick => {
self.clock = current_time(); self.clock = current_time();
let mut reload_task = Task::none();
if let Some(control) = &self.control { if let Some(control) = &self.control {
let poll = control.poll(); let poll = control.poll();
@@ -359,9 +382,12 @@ impl cosmic::Application for FereseShell {
if let Some(active) = poll.overview_active { if let Some(active) = poll.overview_active {
self.overview_active = active; self.overview_active = active;
} }
if let Some(source) = poll.config {
reload_task = self.reload_config(source);
}
if let Some(snapshot) = poll.snapshot { if let Some(snapshot) = poll.snapshot {
self.snapshot = snapshot; self.snapshot = snapshot;
let mut tasks = Vec::new(); let mut tasks = vec![reload_task];
for entry in &mut self.outputs { for entry in &mut self.outputs {
let output = self let output = self
.snapshot .snapshot
@@ -392,13 +418,7 @@ impl cosmic::Application for FereseShell {
return Task::batch(tasks); return Task::batch(tasks);
} }
} }
Task::none() reload_task
}
Message::ActivateWindow(id) => {
if let Some(control) = &self.control {
control.activate_window(id);
}
Task::none()
} }
Message::ActivateWorkspace(id) => { Message::ActivateWorkspace(id) => {
if let Some(control) = &self.control { if let Some(control) = &self.control {
@@ -432,6 +452,62 @@ impl cosmic::Application for FereseShell {
} }
impl 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( fn output_event(
&mut self, &mut self,
event: wayland::OutputEvent, event: wayland::OutputEvent,
@@ -577,13 +653,23 @@ impl FereseShell {
if self.outputs.iter().any(|entry| entry.bar == id) if self.outputs.iter().any(|entry| entry.bar == id)
|| self.menu.as_ref().is_some_and(|menu| menu.id == 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) window::run(id, native_wayland_surface)
.map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface))) .map(move |surface| cosmic::Action::App(Message::NativeSurface(id, surface)))
} }
Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), 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::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Popup(
event, event,
surface, surface,
@@ -596,6 +682,7 @@ impl FereseShell {
wayland::PopupEvent::Done => { wayland::PopupEvent::Done => {
self.menu = None; self.menu = None;
EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed); EFFECT_FRAME_PENDING.store(false, Ordering::Relaxed);
return Task::none();
} }
wayland::PopupEvent::Focused => { wayland::PopupEvent::Focused => {
if menu.effects.is_none() { if menu.effects.is_none() {
@@ -612,6 +699,7 @@ impl FereseShell {
} }
} }
wayland::PopupEvent::Configured { .. } => { wayland::PopupEvent::Configured { .. } => {
menu.motion.begin(Instant::now());
if menu.effects.is_none() { if menu.effects.is_none() {
match EffectsBinding::attach_role( match EffectsBinding::attach_role(
&surface, &surface,
@@ -627,6 +715,9 @@ impl FereseShell {
} }
_ => {} _ => {}
} }
if let Some(effects) = &menu.effects {
let _ = effects.set_opacity(menu.progress());
}
} }
Task::none() Task::none()
} }
@@ -706,25 +797,23 @@ impl FereseShell {
let shell_theme = self.config.theme.for_bar(); let shell_theme = self.config.theme.for_bar();
let bar = BarMetrics::from(shell_theme); let bar = BarMetrics::from(shell_theme);
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
.spacing(1) .spacing(3)
.align_y(cosmic::iced::Alignment::Center); .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))) button::custom(overview_control(bar, color(shell_theme.text_primary)))
.height(bar.control_height) .height(bar.control_height)
.padding([0, 7]) .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)), .on_press(cosmic::Action::App(Message::ToggleOverview)),
); color(shell_theme.text_primary),
self.overview_active,
1.0,
));
for workspace in self for workspace in self
.snapshot .snapshot
.workspaces .workspaces
.iter() .iter()
.filter(|workspace| self.overview_active && workspace.output == focused_output_id) .filter(|workspace| workspace_on_bar(workspace.output, focused_output_id))
{ {
let occupied = self let occupied = self
.snapshot .snapshot
@@ -733,50 +822,35 @@ impl FereseShell {
.any(|window| window.workspace == workspace.id); .any(|window| window.workspace == workspace.id);
let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme); let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme);
let workspace_button = button::custom(indicator) let workspace_button = button::custom(indicator)
.name(format!(
"Workspace {}{}",
workspace.name,
if workspace.active { ", active" } else { "" }
))
.height(bar.control_height) .height(bar.control_height)
.padding(0) .padding(0)
.class(status_ui::button_style(
color(shell_theme.text_primary),
false,
1.0,
))
.on_press(cosmic::Action::App(Message::ActivateWorkspace( .on_press(cosmic::Action::App(Message::ActivateWorkspace(
workspace.id, 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 foreground = color(shell_theme.text_primary);
let mut windows = row::with_capacity(self.snapshot.windows.len()) let left = cosmic::iced::widget::scrollable(workspace_row)
.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)
.direction(cosmic::iced::widget::scrollable::Direction::Horizontal( .direction(cosmic::iced::widget::scrollable::Direction::Horizontal(
cosmic::iced::widget::scrollable::Scrollbar::default() cosmic::iced::widget::scrollable::Scrollbar::default()
.width(0) .width(0)
@@ -784,9 +858,6 @@ impl FereseShell {
)) ))
.width(Length::Fill) .width(Length::Fill)
.height(bar.control_height); .height(bar.control_height);
let left = row![workspace_row, windows]
.spacing(8)
.align_y(cosmic::iced::Alignment::Center);
let clock = container( let clock = container(
text(&self.clock) text(&self.clock)
.size(bar.text_size) .size(bar.text_size)
@@ -943,70 +1014,46 @@ fn workspace_indicator(
bar: BarMetrics, bar: BarMetrics,
shell_theme: ShellTheme, shell_theme: ShellTheme,
) -> Element<'static, cosmic::Action<Message>> { ) -> 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("")) let marker = container(text(""))
.width(width) .width(if active { 18.0 } else { 6.0 })
.height(height) .height(6)
.class(theme::Container::custom(move |_| { .class(theme::Container::custom(move |_| {
workspace_marker_style(color, radius) workspace_selector_style(active, occupied, shell_theme)
})); }));
container(marker) container(marker)
.width(bar.workspace_hit_width) .width(28)
.height(bar.control_height) .height(bar.control_height)
.align_x(alignment::Horizontal::Center) .align_x(alignment::Horizontal::Center)
.align_y(alignment::Vertical::Center) .align_y(alignment::Vertical::Center)
.into() .into()
} }
fn workspace_marker_style(color: Color, radius: f32) -> container::Style { fn workspace_on_bar(workspace_output: Option<u64>, bar_output: Option<u64>) -> bool {
container::Style { matches!((workspace_output, bar_output), (Some(workspace), Some(bar)) if workspace == bar)
background: Some(Background::Color(color)),
border: Border {
radius: radius.into(),
..Border::default()
},
snap: true,
..container::Style::default()
}
} }
fn display_app_name(window: &control::WindowSnapshot) -> String { fn workspace_selector_style(
let identity = window active: bool,
.app_id occupied: bool,
.rsplit('.') shell_theme: ShellTheme,
.find(|part| !part.is_empty()) ) -> container::Style {
.unwrap_or(&window.app_id); container::Style {
background: if active {
if identity.is_empty() { Some(Background::Color(color(shell_theme.accent)))
window.title.clone() } else if occupied {
} else { Some(Background::Color(color_with_opacity(
let mut characters = identity.chars(); shell_theme.text_primary,
characters 0.78,
.next() )))
.map(|first| first.to_uppercase().collect::<String>() + characters.as_str()) } else {
.unwrap_or_else(|| "Desktop".to_owned()) 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 { fn shell_surface_style(theme: ShellTheme) -> cosmic::iced::theme::Style {
cosmic::iced::theme::Style { cosmic::iced::theme::Style {
background_color: Color::TRANSPARENT, background_color: Color::TRANSPARENT,
@@ -1131,6 +1162,7 @@ struct EffectsBinding {
surface: FereseSurfaceEffectsV1, surface: FereseSurfaceEffectsV1,
_queue: EventQueue<EffectsState>, _queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>, regions: std::cell::RefCell<Option<Vec<[i32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>,
} }
impl EffectsBinding { impl EffectsBinding {
@@ -1155,13 +1187,16 @@ impl EffectsBinding {
let connection = Connection::from_backend(backend); let connection = Connection::from_backend(backend);
let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?; let (globals, queue) = registry_queue_init::<EffectsState>(&connection)?;
let qh = queue.handle(); 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, ()); let effects = manager.get_surface_effects(surface, &qh, ());
if let Some(role) = role { if let Some(role) = role {
effects.set_role(role); effects.set_role(role);
} else { } else {
effects.clear_role(); effects.clear_role();
if effects.version() >= 3 {
effects.set_opacity(0);
}
} }
connection.flush()?; connection.flush()?;
@@ -1171,6 +1206,7 @@ impl EffectsBinding {
surface: effects, surface: effects,
_queue: queue, _queue: queue,
regions: Default::default(), 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>> { fn set_visible(&self, visible: bool) -> Result<(), Box<dyn std::error::Error>> {
self.set_opacity(1.0)?;
if visible { if visible {
self.surface self.surface
.set_role(ferese_surface_effects_v1::Role::Panel); .set_role(ferese_surface_effects_v1::Role::Panel);
@@ -1206,6 +1243,15 @@ impl EffectsBinding {
self.connection.flush()?; self.connection.flush()?;
Ok(()) 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; struct EffectsState;
@@ -1300,26 +1346,27 @@ mod tests {
} }
#[test] #[test]
fn window_list_labels_use_titles_and_fall_back_to_the_app_name() { fn workspace_selector_has_distinct_active_paint() {
let snapshot = snapshot_with_fullscreen_window(7); let theme = ShellTheme::default();
let mut window = snapshot.windows[0].clone(); let active = workspace_selector_style(true, false, theme);
window.title = " editor.rs — Project ".to_owned(); let occupied = workspace_selector_style(false, true, theme);
assert_eq!(compact_window_label(&window), "editor.rs — Project"); let inactive = workspace_selector_style(false, false, theme);
window.title = " ".to_owned(); assert!(active.background.is_some());
assert_eq!(compact_window_label(&window), display_app_name(&window)); 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] #[test]
fn window_list_labels_truncate_unicode_without_breaking_characters() { fn workspace_selector_is_output_local() {
let snapshot = snapshot_with_fullscreen_window(7); assert!(workspace_on_bar(Some(1), Some(1)));
let mut window = snapshot.windows[0].clone(); assert!(!workspace_on_bar(Some(2), Some(1)));
window.title = "界".repeat(25); assert!(!workspace_on_bar(None, Some(1)));
assert_eq!( assert!(!workspace_on_bar(None, None));
compact_window_label(&window),
format!("{}…", "界".repeat(24))
);
window.title = "界".repeat(24);
assert_eq!(compact_window_label(&window), window.title);
} }
#[test] #[test]
+437
View File
@@ -6,6 +6,322 @@ use cosmic::{Element, Theme};
pub type Regions = std::sync::Arc<std::sync::Mutex<Vec<[i32; 5]>>>; 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>( pub fn animated<'a, M: 'a>(
content: Element<'a, M>, content: Element<'a, M>,
progress: f32, progress: f32,
@@ -181,6 +497,127 @@ impl widget::Operation for CollectRegions {
mod tests { mod tests {
use super::*; use super::*;
#[test] #[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() { fn material_regions_include_cards_but_not_the_parent_or_gaps() {
let mut collector = CollectRegions(Vec::new()); let mut collector = CollectRegions(Vec::new());
let id = widget::Id::new("ferese-blur-card"); let id = widget::Id::new("ferese-blur-card");
+8
View File
@@ -76,12 +76,15 @@ pub struct Update {
pub error: Option<String>, pub error: Option<String>,
} }
pub struct Service { pub struct Service {
settings: Arc<Mutex<Option<Vec<String>>>>,
tx: SyncSender<(u64, Action)>, tx: SyncSender<(u64, Action)>,
rx: Receiver<Update>, rx: Receiver<Update>,
pub generation: u64, pub generation: u64,
} }
impl Service { impl Service {
pub fn start(settings: Option<Vec<String>>) -> Self { 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 (tx, commands) = mpsc::sync_channel::<(u64, Action)>(64);
let (updates, rx) = mpsc::sync_channel(1); let (updates, rx) = mpsc::sync_channel(1);
// Polling and writes have separate workers: a missing D-Bus service must // Polling and writes have separate workers: a missing D-Bus service must
@@ -132,6 +135,7 @@ impl Service {
state.0 = id; state.0 = id;
state.1 = true; state.1 = true;
} }
let settings = settings.lock().unwrap().clone();
let error = execute(&action, settings.as_deref()).err(); let error = execute(&action, settings.as_deref()).err();
let mut state = shared.0.lock().unwrap(); let mut state = shared.0.lock().unwrap();
state.1 = false; state.1 = false;
@@ -143,6 +147,7 @@ impl Service {
shared.1.notify_one(); shared.1.notify_one();
}); });
Self { Self {
settings: live_settings,
tx, tx,
rx, rx,
generation: 0, generation: 0,
@@ -156,6 +161,9 @@ impl Service {
self.generation = generation; self.generation = generation;
Ok(()) Ok(())
} }
pub fn update_settings(&self, settings: Option<Vec<String>>) {
*self.settings.lock().unwrap() = settings;
}
pub fn poll(&self) -> Option<Update> { pub fn poll(&self) -> Option<Update> {
self.rx.try_iter().last() self.rx.try_iter().last()
} }
+70 -49
View File
@@ -5,7 +5,6 @@ use cosmic::widget::{column, slider};
use status::{Action, Snapshot}; use status::{Action, Snapshot};
const DEVICE_LIST_HEIGHT: f32 = 180.0; const DEVICE_LIST_HEIGHT: f32 = 180.0;
const OPEN_MS: f32 = 160.0;
fn device_list_height(count: usize) -> Option<f32> { fn device_list_height(count: usize) -> Option<f32> {
(count > 4).then_some(DEVICE_LIST_HEIGHT) (count > 4).then_some(DEVICE_LIST_HEIGHT)
@@ -48,30 +47,30 @@ impl Menu {
pub struct OpenMenu { pub struct OpenMenu {
pub id: window::Id, pub id: window::Id,
pub kind: Menu, pub kind: Menu,
opened: Instant, pub motion: super::motion::PopupMotion,
pub confirm: Option<Action>, pub confirm: Option<Action>,
pub effects: Option<EffectsBinding>, pub effects: Option<EffectsBinding>,
pub regions: super::motion::Regions, pub regions: super::motion::Regions,
} }
impl OpenMenu { impl OpenMenu {
pub fn progress(&self) -> f32 { pub fn progress(&self) -> f32 {
let ease = |t: f32| { self.motion.progress()
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)
} }
pub fn animating(&self) -> bool { pub fn animating(&self) -> bool {
self.progress() < 1.0 self.motion.animating()
} }
} }
impl FereseShell { impl FereseShell {
pub fn open_menu(&mut self, kind: Menu, anchor: Rectangle<i32>) -> Task<Message> { 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) { if self.menu.as_ref().is_some_and(|menu| menu.kind == kind) {
// Close atomically. Per-widget alpha cannot fade SVG/text caches if let Some(menu) = &mut self.menu {
// as one surface and leaves bright fragments during the fade. if menu.motion.closing() {
return self.destroy_menu(); menu.motion.retarget(1.0, Instant::now());
return Task::none();
}
}
return self.close_menu();
} }
let destroy = self.destroy_menu(); let destroy = self.destroy_menu();
if !kind.available(&self.status) { if !kind.available(&self.status) {
@@ -81,7 +80,7 @@ impl FereseShell {
self.menu = Some(OpenMenu { self.menu = Some(OpenMenu {
id, id,
kind, kind,
opened: Instant::now(), motion: super::motion::PopupMotion::new(self.config.animations),
confirm: None, confirm: None,
effects: None, effects: None,
regions: Default::default(), 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) { pub fn optimistic_status(&mut self, action: &Action) {
match action { match action {
Action::Volume(v) => { Action::Volume(v) => {
@@ -221,7 +250,6 @@ impl FereseShell {
((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0), ((metrics.height - f32::from(metrics.icon_size)) * 0.5).max(4.0),
]) ])
.height(metrics.height) .height(metrics.height)
.class(button_style(foreground, selected, 1.0))
.on_press_with_rectangle(move |offset, bounds| { .on_press_with_rectangle(move |offset, bounds| {
cosmic::Action::App(Message::OpenMenu( cosmic::Action::App(Message::OpenMenu(
kind, kind,
@@ -233,7 +261,7 @@ impl FereseShell {
}, },
)) ))
}); });
controls = controls.push(control); controls = controls.push(motion::button(control, foreground, selected, 1.0));
} }
controls.into() controls.into()
} }
@@ -243,7 +271,8 @@ impl FereseShell {
return text("").into(); return text("").into();
}; };
let theme = self.config.theme; 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 kind = menu.kind;
let primary = color_with_opacity(theme.text_primary, p); let primary = color_with_opacity(theme.text_primary, p);
let muted = color_with_opacity(theme.text_muted, 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. // Keep the outer surface transparent; only inner cards paint a background.
cosmic::widget::autosize::autosize( 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"), cosmic::iced::advanced::widget::Id::new("ferese-status-menu"),
) )
.limits( .limits(
@@ -661,12 +690,15 @@ fn status_summary<'a>(
..Default::default() ..Default::default()
})); }));
let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle { let badge: Element<'_, cosmic::Action<Message>> = if let Some((_, action)) = toggle {
button::custom(badge) motion::button(
.padding(0) button::custom(badge)
.name(if enabled { "Turn off" } else { "Turn on" }) .padding(0)
.class(button_style(badge_color, false, opacity)) .name(if enabled { "Turn off" } else { "Turn on" })
.on_press(cosmic::Action::App(Message::Control(action))) .on_press(cosmic::Action::App(Message::Control(action))),
.into() badge_color,
false,
opacity,
)
} else { } else {
badge.into() badge.into()
}; };
@@ -800,13 +832,15 @@ fn connection_control<'a>(
.padding(4) .padding(4)
.into(); .into();
}; };
container( container(motion::button(
button::custom(content) button::custom(content)
.width(Length::FillPortion(1)) .width(Length::FillPortion(1))
.padding(4) .padding(4)
.class(button_style(foreground, false, opacity))
.on_press(cosmic::Action::App(Message::Control(action))), .on_press(cosmic::Action::App(Message::Control(action))),
) foreground,
false,
opacity,
))
.id("ferese-blur-card") .id("ferese-blur-card")
.width(Length::FillPortion(1)) .width(Length::FillPortion(1))
.into() .into()
@@ -818,11 +852,14 @@ fn menu_button<'a>(
foreground: Color, foreground: Color,
opacity: f32, opacity: f32,
) -> Element<'a, cosmic::Action<Message>> { ) -> Element<'a, cosmic::Action<Message>> {
button::custom(text(label).size(13)) motion::button(
.padding([6, 8]) button::custom(text(label).size(13))
.class(button_style(foreground, false, opacity)) .padding([6, 8])
.on_press(cosmic::Action::App(message)) .on_press(cosmic::Action::App(message)),
.into() foreground,
false,
opacity,
)
} }
fn toggle_row<'a>( fn toggle_row<'a>(
label: &'a str, label: &'a str,
@@ -843,24 +880,6 @@ fn toggle_row<'a>(
.align_y(Alignment::Center) .align_y(Alignment::Center)
.into() .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( fn slider_row(
source: &'static [u8], source: &'static [u8],
@@ -1071,10 +1090,12 @@ mod tests {
#[test] #[test]
fn completed_open_stops_requesting_animation_ticks() { fn completed_open_stops_requesting_animation_ticks() {
let past = Instant::now() - Duration::from_secs(1); let past = Instant::now() - Duration::from_secs(1);
let mut motion = super::motion::PopupMotion::new(Default::default());
motion.begin(past);
let menu = OpenMenu { let menu = OpenMenu {
id: window::Id::unique(), id: window::Id::unique(),
kind: Menu::Network, kind: Menu::Network,
opened: past, motion,
confirm: None, confirm: None,
effects: None, effects: None,
regions: Default::default(), 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(())
}