Dim and sleep locked displays after inactivity
Add adjustable lock-only inactivity delays, a smooth dimming fade and DRM display sleep without changing output layout. Input, session resume and policy changes wake displays. Pending flips retire before power-off, and protected fallback frames preserve the policy after an early locker crash while unlock ownership checks remain strict. Reduce the password field and avatar, expose delays in Settings, document the practical swaylock comparison and remove the completed backlog. Validation: independent review cleared; 255 compositor tests, 7 locker tests and 15 Settings tests pass; affected debug binaries rebuilt. Isolated visual smoke remains blocked by an unlocked nested screenshot timeout, matching the previously established baseline presentation limitation. Real DRM display sleep requires a live session check.
This commit is contained in:
1 parent
d26e1511d1
commit
35537b32c8
12 files changed
+466
-13
No files matched your search
@@ -184,6 +184,7 @@ struct DirectOutput {
|
||||
damage_tracker: OutputDamageTracker,
|
||||
render_metrics: RenderMetrics,
|
||||
frame_pending: bool,
|
||||
power_off: bool,
|
||||
lock_frame_pending: bool,
|
||||
}
|
||||
|
||||
@@ -268,6 +269,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
output.damage_tracker =
|
||||
OutputDamageTracker::from_output(&output.output);
|
||||
output.frame_pending = false;
|
||||
output.power_off = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -287,6 +289,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
}
|
||||
}
|
||||
tracing::info!("direct session activated");
|
||||
state.lock_input_activity();
|
||||
render_all(state);
|
||||
}
|
||||
})?;
|
||||
@@ -561,6 +564,45 @@ fn open_primary_device(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sleep_locked_outputs(state: &mut Ferese) {
|
||||
if !state.session_lock.active || !state.session_lock.sleeping {
|
||||
return;
|
||||
}
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for device in backend
|
||||
.devices
|
||||
.values_mut()
|
||||
.filter(|device| device.drm.is_active())
|
||||
{
|
||||
for output in device.outputs.values_mut() {
|
||||
if output.power_off || output.frame_pending {
|
||||
continue;
|
||||
}
|
||||
match output.surface.surface().clear() {
|
||||
Ok(()) => output.power_off = true,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, output = %output.output.name(), "could not sleep locked display")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wake_locked_outputs(state: &mut Ferese) {
|
||||
if let Some(backend) = state.direct_backend.as_mut() {
|
||||
for device in backend.devices.values_mut() {
|
||||
for output in device.outputs.values_mut() {
|
||||
if std::mem::take(&mut output.power_off) {
|
||||
output.surface.reset_buffer_ages();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
render_all(state);
|
||||
}
|
||||
|
||||
pub fn render_all(state: &mut Ferese) {
|
||||
let outputs = state
|
||||
.direct_backend
|
||||
@@ -652,6 +694,18 @@ fn lid_hides_panel(closed: bool, external_available: bool) -> bool {
|
||||
}
|
||||
|
||||
fn render_output(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle) {
|
||||
if state.session_lock.active && state.session_lock.sleeping {
|
||||
sleep_locked_outputs(state);
|
||||
let asleep = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
.and_then(|backend| backend.devices.get(&node))
|
||||
.and_then(|device| device.outputs.get(&crtc))
|
||||
.is_none_or(|output| output.power_off || output.frame_pending);
|
||||
if asleep {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let missed_deadlines = state
|
||||
.direct_backend
|
||||
.as_ref()
|
||||
@@ -1318,6 +1372,7 @@ fn create_direct_output(
|
||||
damage_tracker,
|
||||
render_metrics,
|
||||
frame_pending: false,
|
||||
power_off: false,
|
||||
lock_frame_pending: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
notifications: ferese_core::notifications::NotificationConfig,
|
||||
#[serde(default)]
|
||||
lock_screen: crate::session_lock::IdleSettings,
|
||||
#[serde(default)]
|
||||
desktop_widgets: ferese_core::desktop::DesktopWidgets,
|
||||
#[serde(default)]
|
||||
pub(crate) autostart: Vec<DaemonConfig>,
|
||||
@@ -292,6 +294,7 @@ impl Config {
|
||||
let input_settings = self.input_settings()?;
|
||||
let bindings = self.bindings(&input_settings)?;
|
||||
Ok(crate::RuntimeConfig {
|
||||
lock_idle: self.lock_screen.validate()?,
|
||||
autostart: self.autostart.clone(),
|
||||
layout_mode: self.layout_mode(),
|
||||
gap_config: self.gap_config()?,
|
||||
|
||||
@@ -43,6 +43,14 @@ impl Ferese {
|
||||
pub fn process_input_event<I: InputBackend>(&mut self, event: InputEvent<I>) {
|
||||
let seat = self.seat.clone();
|
||||
self.idle_notifier_state.notify_activity(&seat);
|
||||
if !matches!(
|
||||
&event,
|
||||
InputEvent::DeviceAdded { .. }
|
||||
| InputEvent::DeviceRemoved { .. }
|
||||
| InputEvent::SwitchToggle { .. }
|
||||
) {
|
||||
self.lock_input_activity();
|
||||
}
|
||||
|
||||
if self.session_lock.active {
|
||||
// An already-bound IME or drag client may install a grab after the
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use crate::Ferese;
|
||||
use smithay::reexports::calloop::{
|
||||
RegistrationToken,
|
||||
timer::{TimeoutAction, Timer},
|
||||
};
|
||||
use smithay::{
|
||||
output::Output,
|
||||
reexports::{
|
||||
@@ -16,7 +20,72 @@ use smithay::{
|
||||
LockSurface, SessionLockHandler, SessionLockManagerState, SessionLockState, SessionLocker,
|
||||
},
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct IdleSettings {
|
||||
pub dim_after_seconds: u64,
|
||||
pub sleep_after_seconds: u64,
|
||||
}
|
||||
|
||||
impl Default for IdleSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dim_after_seconds: 30,
|
||||
sleep_after_seconds: 120,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IdleSettings {
|
||||
pub(crate) fn validate(self) -> Result<Self, crate::config::ConfigError> {
|
||||
if self.dim_after_seconds > 86400 || self.sleep_after_seconds > 86400 {
|
||||
return Err(crate::config::ConfigError::InvalidInputValue {
|
||||
field: "lock_screen.idle_delay",
|
||||
value: "must be between 0 and 86400 seconds".into(),
|
||||
});
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn next_poll(self, elapsed: Duration, animate: bool) -> Duration {
|
||||
let dim_at = Duration::from_secs(self.dim_after_seconds);
|
||||
if animate
|
||||
&& self.dim_after_seconds != 0
|
||||
&& elapsed >= dim_at
|
||||
&& elapsed < dim_at + Duration::from_millis(500)
|
||||
{
|
||||
return Duration::from_millis(16);
|
||||
}
|
||||
[self.dim_after_seconds, self.sleep_after_seconds]
|
||||
.into_iter()
|
||||
.filter(|delay| *delay != 0)
|
||||
.filter_map(|delay| Duration::from_secs(delay).checked_sub(elapsed))
|
||||
.filter(|delay| !delay.is_zero())
|
||||
.min()
|
||||
.unwrap_or(Duration::from_secs(1))
|
||||
.min(Duration::from_secs(1))
|
||||
}
|
||||
|
||||
fn appearance(self, elapsed: Duration) -> (f32, bool) {
|
||||
let sleeping = self.sleep_after_seconds != 0
|
||||
&& elapsed >= Duration::from_secs(self.sleep_after_seconds);
|
||||
let dim = if self.dim_after_seconds == 0 {
|
||||
0.0
|
||||
} else {
|
||||
elapsed
|
||||
.saturating_sub(Duration::from_secs(self.dim_after_seconds))
|
||||
.as_secs_f32()
|
||||
.min(0.5)
|
||||
* 1.3
|
||||
};
|
||||
(if sleeping { 1.0 } else { dim }, sleeping)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Lock {
|
||||
@@ -27,9 +96,79 @@ pub(crate) struct Lock {
|
||||
pub backgrounds: HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>,
|
||||
presented: HashSet<Output>,
|
||||
confirmed: bool,
|
||||
idle_since: Option<Instant>,
|
||||
pub(crate) idle_opacity: f32,
|
||||
pub(crate) sleeping: bool,
|
||||
pub(crate) idle_overlays:
|
||||
HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>,
|
||||
idle_timer: Option<RegistrationToken>,
|
||||
}
|
||||
|
||||
impl Lock {
|
||||
fn ready_for_idle<'a>(&self, outputs: impl Iterator<Item = &'a Output>) -> bool {
|
||||
self.active
|
||||
&& outputs
|
||||
.into_iter()
|
||||
.all(|output| self.presented.contains(output))
|
||||
}
|
||||
|
||||
pub(crate) fn output_added(&mut self, output: &Output) {
|
||||
self.presented.remove(output);
|
||||
}
|
||||
|
||||
pub(crate) fn output_removed(&mut self, output: &Output) {
|
||||
self.presented.remove(output);
|
||||
self.idle_overlays.remove(output);
|
||||
}
|
||||
|
||||
fn activity(&mut self, now: Instant) -> bool {
|
||||
if !self.active {
|
||||
return false;
|
||||
}
|
||||
let changed = self.idle_opacity != 0.0 || self.sleeping;
|
||||
self.idle_since = Some(now);
|
||||
self.idle_opacity = 0.0;
|
||||
self.sleeping = false;
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn lock_input_activity(&mut self) {
|
||||
if self.session_lock.activity(Instant::now()) {
|
||||
crate::backends::direct::wake_locked_outputs(self);
|
||||
self.cursor_redraw_pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_lock_idle(&mut self, now: Instant) {
|
||||
if !self.session_lock.ready_for_idle(self.space.outputs()) {
|
||||
return;
|
||||
}
|
||||
let since = self.session_lock.idle_since.get_or_insert(now);
|
||||
let (mut opacity, sleeping) = self
|
||||
.lock_idle
|
||||
.appearance(now.saturating_duration_since(*since));
|
||||
if !self.animations_enabled() && opacity > 0.0 && !sleeping {
|
||||
opacity = 0.65;
|
||||
}
|
||||
let changed =
|
||||
opacity != self.session_lock.idle_opacity || sleeping != self.session_lock.sleeping;
|
||||
let was_sleeping = self.session_lock.sleeping;
|
||||
self.session_lock.idle_opacity = opacity;
|
||||
self.session_lock.sleeping = sleeping;
|
||||
if changed {
|
||||
if was_sleeping && !sleeping {
|
||||
crate::backends::direct::wake_locked_outputs(self);
|
||||
} else if sleeping {
|
||||
crate::backends::direct::sleep_locked_outputs(self);
|
||||
} else {
|
||||
crate::backends::direct::render_all(self);
|
||||
}
|
||||
self.cursor_redraw_pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lock_surface_under(
|
||||
&self,
|
||||
position: Point<f64, Logical>,
|
||||
@@ -123,6 +262,28 @@ impl SessionLockHandler for Ferese {
|
||||
}
|
||||
self.cancel_logout_confirmation();
|
||||
self.session_lock.active = true;
|
||||
self.session_lock.idle_since = Some(Instant::now());
|
||||
match self.loop_handle.insert_source(
|
||||
Timer::from_duration(Duration::from_secs(1)),
|
||||
|_, _, state| {
|
||||
if !state.session_lock.active {
|
||||
return TimeoutAction::Drop;
|
||||
}
|
||||
state.update_lock_idle(Instant::now());
|
||||
let elapsed = state
|
||||
.session_lock
|
||||
.idle_since
|
||||
.map_or(Duration::ZERO, |since| since.elapsed());
|
||||
TimeoutAction::ToDuration(
|
||||
state
|
||||
.lock_idle
|
||||
.next_poll(elapsed, state.animations_enabled()),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Ok(token) => self.session_lock.idle_timer = Some(token),
|
||||
Err(error) => tracing::warn!(%error, "could not schedule lock screen inactivity"),
|
||||
}
|
||||
self.input_capture.disable_all();
|
||||
self.portal_session.set_locked(true);
|
||||
self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
|
||||
@@ -166,7 +327,14 @@ impl SessionLockHandler for Ferese {
|
||||
}
|
||||
|
||||
fn unlock(&mut self) {
|
||||
if let Some(timer) = self.session_lock.idle_timer.take() {
|
||||
self.loop_handle.remove(timer);
|
||||
}
|
||||
let sleeping = self.session_lock.sleeping;
|
||||
self.session_lock = Lock::default();
|
||||
if sleeping {
|
||||
crate::backends::direct::wake_locked_outputs(self);
|
||||
}
|
||||
self.portal_session.set_locked(false);
|
||||
self.restore_keyboard_focus();
|
||||
crate::backends::direct::render_all(self);
|
||||
@@ -223,3 +391,121 @@ impl Dispatch<ExtSessionLockV1, SessionLockState> for Ferese {
|
||||
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);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn idle_policy_fades_then_sleeps_and_activity_resets_it() {
|
||||
let policy = IdleSettings::default();
|
||||
assert_eq!(policy.appearance(Duration::from_secs(29)), (0.0, false));
|
||||
let halfway = policy.appearance(Duration::from_millis(30_250));
|
||||
assert!((halfway.0 - 0.325).abs() < 0.001 && !halfway.1);
|
||||
assert_eq!(policy.appearance(Duration::from_secs(31)), (0.65, false));
|
||||
assert_eq!(policy.appearance(Duration::from_secs(120)), (1.0, true));
|
||||
assert_eq!(policy.appearance(Duration::ZERO), (0.0, false));
|
||||
assert_eq!(
|
||||
policy.next_poll(Duration::from_millis(29_998), true),
|
||||
Duration::from_millis(2)
|
||||
);
|
||||
assert_eq!(
|
||||
policy.next_poll(Duration::from_secs(30), true),
|
||||
Duration::from_millis(16)
|
||||
);
|
||||
assert_eq!(
|
||||
policy.next_poll(Duration::from_secs(30), false),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_fallback_frames_allow_idle_after_early_locker_crash() {
|
||||
let output = Output::new(
|
||||
"test".into(),
|
||||
smithay::output::PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: smithay::output::Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
let mut lock = Lock {
|
||||
active: true,
|
||||
..Lock::default()
|
||||
};
|
||||
assert!(!lock.ready_for_idle(std::iter::once(&output)));
|
||||
lock.presented.insert(output.clone());
|
||||
assert!(lock.ready_for_idle(std::iter::once(&output)));
|
||||
assert!(!lock.confirmed);
|
||||
lock.output_added(&output);
|
||||
assert!(!lock.ready_for_idle(std::iter::once(&output)));
|
||||
assert!(!lock.confirmed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_wakes_a_sleeping_lock_without_unlocking_or_losing_confirmation() {
|
||||
let mut lock = Lock {
|
||||
active: true,
|
||||
confirmed: true,
|
||||
sleeping: true,
|
||||
idle_opacity: 1.0,
|
||||
..Lock::default()
|
||||
};
|
||||
let now = Instant::now();
|
||||
assert!(lock.activity(now));
|
||||
assert!(lock.active && lock.confirmed);
|
||||
assert!(!lock.sleeping);
|
||||
assert_eq!(lock.idle_opacity, 0.0);
|
||||
assert_eq!(lock.idle_since, Some(now));
|
||||
assert!(!lock.activity(now));
|
||||
let mut unlocked = Lock::default();
|
||||
assert!(!unlocked.activity(now));
|
||||
assert!(unlocked.idle_since.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_disabled_options_are_independent() {
|
||||
assert_eq!(
|
||||
IdleSettings {
|
||||
dim_after_seconds: 0,
|
||||
sleep_after_seconds: 0
|
||||
}
|
||||
.appearance(Duration::from_secs(9999)),
|
||||
(0.0, false)
|
||||
);
|
||||
assert_eq!(
|
||||
IdleSettings {
|
||||
dim_after_seconds: 0,
|
||||
sleep_after_seconds: 10
|
||||
}
|
||||
.appearance(Duration::from_secs(10)),
|
||||
(1.0, true)
|
||||
);
|
||||
assert_eq!(
|
||||
IdleSettings {
|
||||
dim_after_seconds: 1,
|
||||
sleep_after_seconds: 0
|
||||
}
|
||||
.appearance(Duration::from_secs(9999)),
|
||||
(0.65, false)
|
||||
);
|
||||
assert!(
|
||||
IdleSettings {
|
||||
dim_after_seconds: 86401,
|
||||
sleep_after_seconds: 0
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::config::Config::parse_source("lock-screen { dim-after-seconds -1; }").is_err()
|
||||
);
|
||||
assert!(
|
||||
crate::config::Config::parse_source("lock-screen { sleep-after-seconds 90000; }")
|
||||
.unwrap()
|
||||
.runtime_config()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,7 @@ 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) lock_idle: crate::session_lock::IdleSettings,
|
||||
pub(crate) config_source: Option<String>,
|
||||
pub start_time: Instant,
|
||||
pub socket_name: OsString,
|
||||
@@ -353,6 +354,7 @@ pub struct Ferese {
|
||||
}
|
||||
|
||||
pub struct RuntimeConfig {
|
||||
pub(crate) lock_idle: crate::session_lock::IdleSettings,
|
||||
pub(crate) autostart: Vec<crate::config::DaemonConfig>,
|
||||
pub(crate) overview_font_family: String,
|
||||
pub(crate) wallpaper: crate::wallpaper::WallpaperConfig,
|
||||
@@ -447,6 +449,7 @@ impl Ferese {
|
||||
let mut state = Self {
|
||||
session_lock_state,
|
||||
session_lock: crate::session_lock::Lock::default(),
|
||||
lock_idle: config.lock_idle,
|
||||
config_source: None,
|
||||
start_time,
|
||||
socket_name,
|
||||
@@ -609,6 +612,7 @@ impl Ferese {
|
||||
);
|
||||
}
|
||||
|
||||
self.lock_idle = config.lock_idle;
|
||||
self.advance_animations(Instant::now());
|
||||
|
||||
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad;
|
||||
@@ -677,6 +681,10 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn register_output(&mut self, output: &Output, identity: String) {
|
||||
if self.session_lock.active {
|
||||
self.session_lock.output_added(output);
|
||||
self.lock_input_activity();
|
||||
}
|
||||
let Some(geometry) = self.space.output_geometry(output) else {
|
||||
tracing::error!(output = %output.name(), "cannot register an unmapped output");
|
||||
return;
|
||||
@@ -738,6 +746,7 @@ impl Ferese {
|
||||
self.space.unmap_output(output);
|
||||
self.session_lock.surfaces.remove(output);
|
||||
self.session_lock.backgrounds.remove(output);
|
||||
self.session_lock.output_removed(output);
|
||||
|
||||
match self.output_workspaces.disconnect(output_id) {
|
||||
Ok(Some(target)) => {
|
||||
|
||||
@@ -1148,6 +1148,30 @@ fn output_elements(
|
||||
});
|
||||
background.resize(geometry.size);
|
||||
let mut elements = Vec::new();
|
||||
let opacity = state.session_lock.idle_opacity;
|
||||
if opacity > 0.0 {
|
||||
let overlay = state
|
||||
.session_lock
|
||||
.idle_overlays
|
||||
.entry(output.clone())
|
||||
.or_insert_with(|| {
|
||||
smithay::backend::renderer::element::solid::SolidColorBuffer::new(
|
||||
geometry.size,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
)
|
||||
});
|
||||
overlay.resize(geometry.size);
|
||||
elements.push(
|
||||
SolidColorRenderElement::from_buffer(
|
||||
overlay,
|
||||
(0, 0),
|
||||
scale,
|
||||
opacity,
|
||||
RenderElementKind::Unspecified,
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if let Some(surface) = state
|
||||
.session_lock
|
||||
.surfaces
|
||||
|
||||
Reference in new issue
Block a user