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