From 2a400b26d0687232d37aa2025e4180c32c14bc47 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Tue, 13 Oct 2026 14:37:26 +0100 Subject: [PATCH] Add gamma control and night light (#38) Add configurable night light, smooth gamma transitions, and shell controls. Update documentation and panel tests, and fix portal test startup synchronization. --- compositor/src/backends/direct.rs | 10 +- compositor/src/backends/direct/gamma.rs | 390 ++++++++++++++++++ compositor/src/backends/direct/transaction.rs | 1 + compositor/src/config.rs | 3 + compositor/src/gamma.rs | 93 +++++ compositor/src/handlers/gamma_control.rs | 97 +++++ compositor/src/handlers/mod.rs | 1 + compositor/src/ipc.rs | 3 + compositor/src/main.rs | 3 + compositor/src/state.rs | 10 + crates/ferese-config/src/lib.rs | 1 + crates/ferese-config/src/night_light.rs | 26 ++ crates/ferese-ipc/src/lib.rs | 1 + crates/ferese-ipc/src/night_light.rs | 10 + crates/ferese-panel/src/panel.rs | 7 +- crates/ferese-panel/src/panel/layout.rs | 11 +- docs/configuration/outputs.md | 41 ++ docs/configuration/panel.md | 5 + docs/panel-configuration.md | 1 + packaging/config.kdl | 1 + scripts/tests/test_portal_isolated.py | 3 +- shell/ferese-settings/src/panel_controls.rs | 4 +- shell/ferese-settings/src/panel_edit.rs | 10 +- shell/ferese-settings/src/panel_preview.rs | 1 + shell/ferese-shell/src/bar.rs | 8 +- shell/ferese-shell/src/main.rs | 15 + shell/ferese-shell/src/panel_hub.rs | 32 +- shell/ferese-shell/src/status.rs | 16 + shell/ferese-shell/src/status/night_light.rs | 72 ++++ shell/ferese-shell/src/status_ui/bar.rs | 11 + shell/ferese-shell/src/status_ui/lifecycle.rs | 18 +- shell/ferese-shell/src/status_ui/mod.rs | 11 +- .../ferese-shell/src/status_ui/night_light.rs | 43 ++ shell/ferese-shell/src/status_ui/system.rs | 1 + 34 files changed, 940 insertions(+), 20 deletions(-) create mode 100644 compositor/src/backends/direct/gamma.rs create mode 100644 compositor/src/gamma.rs create mode 100644 compositor/src/handlers/gamma_control.rs create mode 100644 crates/ferese-config/src/night_light.rs create mode 100644 crates/ferese-ipc/src/night_light.rs create mode 100644 shell/ferese-shell/src/status/night_light.rs create mode 100644 shell/ferese-shell/src/status_ui/night_light.rs diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 137a7d9..8335104 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; pub(crate) mod capture; mod crtc_assignment; +pub(crate) mod gamma; mod lid; mod mirror; mod output_power; @@ -254,6 +255,7 @@ struct DirectDevice { } struct DirectOutput { + gamma: gamma::State, internal: bool, connector: connector::Handle, mode: DrmMode, @@ -403,8 +405,8 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() .values_mut() .for_each(PresentationClock::reset_timing); backend.devices.values_mut().for_each(|device| { - device.drm.pause(); device.outputs.values_mut().for_each(|output| { + output.gamma.suspend(); cancel_output_timers(&handle, output); output.surface.reset_buffers(); output.primary_commit = None; @@ -412,6 +414,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() output.frame_pending = false; output.power.suspended(); }); + device.drm.pause(); }); } @@ -887,6 +890,10 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result), +} + +impl State { + fn probe(&mut self, drm: &DrmDevice, crtc: crtc::Handle) -> Option<&mut Gamma> { + if matches!(self, Self::Unprobed) { + *self = match Gamma::new(drm, crtc) { + Ok(gamma) => Self::Ready(Box::new(gamma)), + Err(error) => { + tracing::debug!(%error, ?crtc, "output gamma unavailable"); + Self::Unsupported + } + }; + } + self.controller_mut() + } + + fn controller(&self) -> Option<&Gamma> { + match self { + Self::Ready(gamma) => Some(gamma), + _ => None, + } + } + + fn controller_mut(&mut self) -> Option<&mut Gamma> { + match self { + Self::Ready(gamma) => Some(gamma), + _ => None, + } + } + + pub(super) fn suspend(&mut self) { + if let Some(gamma) = self.controller_mut() { + gamma.suspend(); + } + } + + pub(super) fn retry_probe(&mut self) { + if matches!(self, Self::Unsupported) { + *self = Self::Unprobed; + } + } +} + +pub(super) struct Gamma { + fd: DrmDeviceFd, + crtc: crtc::Handle, + original: Ramp, + current: Ramp, + transition: Option, + source: Source, + suspended: bool, + modified: bool, + failed: bool, +} + +impl Gamma { + fn new(drm: &DrmDevice, crtc: crtc::Handle) -> io::Result { + let mut original = Ramp::zeroed(drm.get_crtc(crtc)?.gamma_length() as usize)?; + let (red, green, blue) = original.channels_mut(); + drm.get_gamma(crtc, red, green, blue)?; + Ok(Self { + fd: drm.device_fd().clone(), + crtc, + current: original.clone(), + transition: None, + original, + source: Source::Original, + suspended: false, + modified: false, + failed: false, + }) + } + + fn write(&self, ramp: &Ramp) -> io::Result<()> { + if self.fd.get_crtc(self.crtc)?.gamma_length() as usize != ramp.size() { + return Err(io::ErrorKind::InvalidData.into()); + } + let (red, green, blue) = ramp.channels(); + self.fd.set_gamma(self.crtc, red, green, blue) + } + + fn restore(&mut self) { + if self.suspended || !self.modified { + return; + } + match self.write(&self.original) { + Ok(()) => { + self.modified = false; + self.current = self.original.clone(); + } + Err(error) => tracing::warn!(%error, ?self.crtc, "could not restore output gamma"), + } + } + + fn replace_source(&mut self, source: Source) { + if let Source::Client { control, .. } = std::mem::replace(&mut self.source, source) { + control.failed(); + } + } + + fn configure(&mut self, settings: Settings, duration: Duration, now: Instant) { + if settings.enabled { + if matches!(self.source, Source::NightLight { temperature, .. } if temperature == settings.temperature) { + return; + } + self.replace_source(Source::NightLight { + temperature: settings.temperature, + ramp: self.original.at_temperature(settings.temperature), + }); + } else if matches!(self.source, Source::NightLight { .. }) { + self.source = Source::Original; + } else { + return; + } + let target = match &self.source { + Source::Original => &self.original, + Source::NightLight { ramp, .. } | Source::Client { ramp, .. } => ramp, + }; + self.transition = (!duration.is_zero() && !self.suspended && self.current != *target).then(|| Transition { + from: self.current.clone(), + started: now, + duration, + }); + } + + fn owns(&self, owner: &ZwlrGammaControlV1) -> bool { + matches!(&self.source, Source::Client { control, .. } if control == owner) + } + + pub(super) fn suspend(&mut self) { + self.transition = None; + self.restore(); + self.suspended = true; + } + + fn resume(&mut self, now: Instant) { + self.suspended = false; + let ramp = match &self.source { + Source::Original if !self.modified && self.transition.is_none() => { + self.failed = false; + return; + } + Source::Original => &self.original, + Source::Client { ramp, .. } | Source::NightLight { ramp, .. } => ramp, + }; + let animated; + let ramp = if let Some(transition) = &self.transition { + let progress = + now.saturating_duration_since(transition.started).as_secs_f64() / transition.duration.as_secs_f64(); + if progress >= 1. { + self.transition = None; + ramp + } else { + let eased = progress * progress * (3. - 2. * progress); + animated = transition.from.interpolate(ramp, eased); + &animated + } + } else { + ramp + }; + // Even a failed driver call may need the original table restored. + self.modified |= ramp != &self.original; + match self.write(ramp) { + Ok(()) => { + self.current = ramp.clone(); + self.modified = self.current != self.original; + self.failed = false; + } + Err(error) => { + self.transition = None; + self.failed = true; + tracing::warn!(%error, ?self.crtc, "could not apply output gamma"); + if matches!(self.source, Source::Client { .. }) { + self.replace_source(Source::Original); + } + self.restore(); + } + } + } +} + +impl Drop for Gamma { + fn drop(&mut self) { + self.replace_source(Source::Original); + self.restore(); + } +} + +pub(crate) fn acquire(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1) -> Option { + // Reconcile a just-reloaded configuration before assigning ownership. + sync_night_light(state); + if state.night_light.enabled { + return None; + } + let duration = state.animation_duration(TRANSITION_DURATION); + let backend = state.direct_backend.as_mut().filter(|backend| backend.active)?; + for device in backend.devices.values_mut().filter(|device| device.drm.is_active()) { + for (crtc, output) in &mut device.outputs { + if &output.output != target || output.global.is_none() { + continue; + } + let gamma = output.gamma.probe(&device.drm, *crtc)?; + gamma.configure(state.night_light, duration, Instant::now()); + if !matches!(gamma.source, Source::Original) { + return None; + } + gamma.transition = None; + gamma.source = Source::Client { + control: owner.clone(), + ramp: gamma.original.clone(), + }; + return Some(gamma.original.size()); + } + } + None +} + +pub(crate) fn sync_night_light(state: &mut Ferese) { + if !state.gamma_dirty { + return; + } + if !state.direct_backend.as_ref().is_some_and(|backend| backend.active) { + return; + } + state.gamma_dirty = false; + if advance_night_light(state, true) && !state.gamma_timer_pending { + let result = state + .loop_handle + .insert_source(Timer::from_duration(TRANSITION_INTERVAL), |_, _, state| { + if advance_night_light(state, false) { + TimeoutAction::ToDuration(TRANSITION_INTERVAL) + } else { + state.gamma_timer_pending = false; + TimeoutAction::Drop + } + }); + match result { + Ok(_) => state.gamma_timer_pending = true, + Err(error) => { + tracing::warn!(%error, "could not schedule night-light transition"); + if let Some(backend) = &mut state.direct_backend { + for device in backend.devices.values_mut().filter(|device| device.drm.is_active()) { + for output in device.outputs.values_mut().filter(|output| !output.power.needs_wake()) { + if let Some(gamma) = output.gamma.controller_mut() { + gamma.transition = None; + gamma.resume(Instant::now()); + } + } + } + } + } + } + } +} + +fn advance_night_light(state: &mut Ferese, force: bool) -> bool { + let duration = state.animation_duration(TRANSITION_DURATION); + let now = Instant::now(); + let Some(backend) = state.direct_backend.as_mut().filter(|backend| backend.active) else { + return false; + }; + let mut pending = false; + for device in backend.devices.values_mut().filter(|device| device.drm.is_active()) { + for (crtc, output) in &mut device.outputs { + let ready = !output.power.needs_wake(); + let gamma = if ready { + output.gamma.probe(&device.drm, *crtc) + } else { + output.gamma.controller_mut() + }; + if let Some(gamma) = gamma { + gamma.configure(state.night_light, duration, now); + let update = force || gamma.transition.is_some(); + if duration.is_zero() { + gamma.transition = None; + } + if ready && update { + gamma.resume(now); + pending |= gamma.transition.is_some(); + } + } + } + } + pending +} + +pub(crate) fn night_light_status(state: &Ferese) -> Status { + let mut status = Status { + enabled: state.night_light.enabled, + temperature: state.night_light.temperature, + ..Default::default() + }; + if let Some(backend) = &state.direct_backend { + for device in backend.devices.values() { + for output in device.outputs.values() { + if let Some(gamma) = output.gamma.controller() { + status.supported_outputs += 1; + status.failed_outputs += usize::from(gamma.failed); + status.active_outputs += usize::from( + backend.active + && !output.power.needs_wake() + && matches!(gamma.source, Source::NightLight { .. }) + && !gamma.failed + && !gamma.suspended, + ); + } + } + } + } + status +} + +fn owned<'a>(state: &'a mut Ferese, target: &Output, owner: &ZwlrGammaControlV1) -> Option<(&'a mut Gamma, bool)> { + let duration = state.animation_duration(TRANSITION_DURATION); + let backend = state.direct_backend.as_mut()?; + for device in backend.devices.values_mut() { + for output in device.outputs.values_mut() { + if &output.output == target { + let ready = backend.active && device.drm.is_active() && !output.power.needs_wake(); + let gamma = output.gamma.controller_mut()?; + gamma.configure(state.night_light, duration, Instant::now()); + return gamma.owns(owner).then_some((gamma, ready)); + } + } + } + None +} + +pub(crate) fn size(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1) -> Option { + owned(state, target, owner).map(|(gamma, _)| gamma.original.size()) +} + +pub(crate) fn set(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1, ramp: Ramp) { + if let Some((gamma, ready)) = owned(state, target, owner) { + gamma.transition = None; + gamma.source = Source::Client { + control: owner.clone(), + ramp, + }; + if ready { + gamma.resume(Instant::now()); + } + } +} + +pub(crate) fn release(state: &mut Ferese, target: &Output, owner: &ZwlrGammaControlV1) { + if let Some((gamma, ready)) = owned(state, target, owner) { + gamma.transition = None; + gamma.source = Source::Original; + if ready { + gamma.resume(Instant::now()); + } + } +} diff --git a/compositor/src/backends/direct/transaction.rs b/compositor/src/backends/direct/transaction.rs index 7c2642b..a448f2c 100644 --- a/compositor/src/backends/direct/transaction.rs +++ b/compositor/src/backends/direct/transaction.rs @@ -208,6 +208,7 @@ fn prepare( /// Retire a logical output only after its hardware is unavailable or confirmed disabled. fn retire(state: &mut Ferese, output: &mut DirectOutput, node: DrmNode, crtc: crtc::Handle) { + output.gamma = Default::default(); cancel_output_timers(&state.loop_handle, output); if let Some(global) = output.global.take() { state.display_handle.disable_global::(global); diff --git a/compositor/src/config.rs b/compositor/src/config.rs index b9de62b..a4907ab 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -45,6 +45,8 @@ pub struct Config { #[serde(default)] lock_screen: crate::session_lock::IdleSettings, #[serde(default)] + night_light: ferese_config::night_light::Settings, + #[serde(default)] idle_inhibit: crate::idle_inhibition::Settings, #[serde(default)] pub(crate) autostart: Vec, @@ -141,6 +143,7 @@ impl Config { activity_matcher: ferese_config::activities::Matcher::new(&self.activities) .map_err(ConfigError::Binding)?, lock_idle: self.lock_screen.validate()?, + night_light: self.night_light.validate().map_err(ConfigError::Binding)?, idle_inhibit: self.idle_inhibit, autostart: self.autostart.clone(), layout_mode: self.layout_mode(), diff --git a/compositor/src/gamma.rs b/compositor/src/gamma.rs new file mode 100644 index 0000000..ef1367d --- /dev/null +++ b/compositor/src/gamma.rs @@ -0,0 +1,93 @@ +//! Gamma ramps shared by display backends and the gamma-control protocol. +use std::fs::File; +use std::io; +use std::os::unix::fs::FileExt; + +// Keep client-controlled allocation bounded even if a driver reports an invalid size. +const MAX_GAMMA_SIZE: usize = 65_536; + +#[derive(Clone, PartialEq)] +pub(crate) struct Ramp(Vec); + +impl Ramp { + pub(crate) fn zeroed(size: usize) -> io::Result { + if size == 0 || size > MAX_GAMMA_SIZE { + return Err(io::ErrorKind::Unsupported.into()); + } + Ok(Self(vec![0; size * 3])) + } + + pub(crate) fn size(&self) -> usize { + self.0.len() / 3 + } + + pub(crate) fn channels_mut(&mut self) -> (&mut [u16], &mut [u16], &mut [u16]) { + let size = self.size(); + let (red, rest) = self.0.split_at_mut(size); + let (green, blue) = rest.split_at_mut(size); + (red, green, blue) + } + + pub(crate) fn at_temperature(&self, temperature: u16) -> Self { + let mut ramp = self.clone(); + for (channel, gain) in ramp.0.chunks_exact_mut(self.size()).zip(temperature_gains(temperature)) { + for value in channel { + *value = (f64::from(*value) * gain).round() as u16; + } + } + ramp + } + + pub(crate) fn interpolate(&self, target: &Self, progress: f64) -> Self { + assert_eq!(self.size(), target.size()); + let progress = progress.clamp(0., 1.); + Self( + self.0 + .iter() + .zip(&target.0) + .map(|(&from, &to)| (f64::from(from) + (f64::from(to) - f64::from(from)) * progress).round() as u16) + .collect(), + ) + } + + pub(crate) fn read(file: File, size: usize) -> io::Result { + let metadata = file.metadata()?; + let length = size.checked_mul(6).filter(|_| size > 0 && size <= MAX_GAMMA_SIZE); + let Some(length) = length else { + return Err(io::ErrorKind::InvalidData.into()); + }; + // pread preserves the caller's offset and avoids mmap's truncation SIGBUS. + // Only fixed-size files are accepted, not pipes or device streams. + if !metadata.is_file() || metadata.len() != length as u64 { + return Err(io::ErrorKind::InvalidData.into()); + } + let mut bytes = vec![0; length]; + file.read_exact_at(&mut bytes, 0)?; + Ok(Self( + bytes + .chunks_exact(2) + .map(|v| u16::from_ne_bytes([v[0], v[1]])) + .collect(), + )) + } + + pub(crate) fn channels(&self) -> (&[u16], &[u16], &[u16]) { + let size = self.0.len() / 3; + (&self.0[..size], &self.0[size..2 * size], &self.0[2 * size..]) + } +} + +fn temperature_gains(kelvin: u16) -> [f64; 3] { + // Tanner Helland's temperature approximation, normalized to neutral at 6500 K. + // https://tannerhelland.com/2012/09/18/convert-temperature-rgb-algorithm-code.html + let green = |t: f64| (99.4708025861 * t.ln() - 161.1195681661).clamp(0., 255.); + let blue = |t: f64| { + if t <= 19. { + 0. + } else { + (138.5177312231 * (t - 10.).ln() - 305.0447927307).clamp(0., 255.) + } + }; + let temperature = f64::from(kelvin.clamp(1000, 6500)) / 100.; + [1., green(temperature) / green(65.), blue(temperature) / blue(65.)] +} diff --git a/compositor/src/handlers/gamma_control.rs b/compositor/src/handlers/gamma_control.rs new file mode 100644 index 0000000..5d1be4f --- /dev/null +++ b/compositor/src/handlers/gamma_control.rs @@ -0,0 +1,97 @@ +use std::fs::File; + +use smithay::output::Output; +use smithay::reexports::wayland_protocols_wlr::gamma_control::v1::server::{ + zwlr_gamma_control_manager_v1::{self, ZwlrGammaControlManagerV1}, + zwlr_gamma_control_v1::{self, ZwlrGammaControlV1}, +}; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource}; + +use crate::{Ferese, backends::direct::gamma, gamma::Ramp}; + +pub(crate) fn init_global(display: &DisplayHandle) { + display.create_global::(1, ()); +} + +impl GlobalDispatch for Ferese { + fn bind( + _state: &mut Self, + _display: &DisplayHandle, + _client: &Client, + resource: New, + _data: &(), + data_init: &mut DataInit<'_, Self>, + ) { + data_init.init(resource, ()); + } +} + +impl Dispatch for Ferese { + fn request( + state: &mut Self, + _client: &Client, + _resource: &ZwlrGammaControlManagerV1, + request: zwlr_gamma_control_manager_v1::Request, + _data: &(), + _display: &DisplayHandle, + data_init: &mut DataInit<'_, Self>, + ) { + match request { + zwlr_gamma_control_manager_v1::Request::GetGammaControl { id, output } => { + let output = Output::from_resource(&output); + let control = data_init.init(id, output.clone()); + if let Some(size) = output + .as_ref() + .and_then(|output| gamma::acquire(state, output, &control)) + { + control.gamma_size(size as u32); + } else { + control.failed(); + } + } + zwlr_gamma_control_manager_v1::Request::Destroy => {} + _ => unreachable!(), + } + } +} + +impl Dispatch> for Ferese { + fn request( + state: &mut Self, + _client: &Client, + control: &ZwlrGammaControlV1, + request: zwlr_gamma_control_v1::Request, + output: &Option, + _display: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + match request { + zwlr_gamma_control_v1::Request::SetGamma { fd } => { + let Some(output) = output else { return }; + let Some(size) = gamma::size(state, output, control) else { + return; + }; + match Ramp::read(File::from(fd), size) { + Ok(ramp) => gamma::set(state, output, control, ramp), + Err(error) => control.post_error( + zwlr_gamma_control_v1::Error::InvalidGamma, + format!("Invalid gamma table: {error}"), + ), + } + } + zwlr_gamma_control_v1::Request::Destroy => {} + _ => unreachable!(), + } + } + + fn destroyed( + state: &mut Self, + _client: smithay::reexports::wayland_server::backend::ClientId, + control: &ZwlrGammaControlV1, + output: &Option, + ) { + if let Some(output) = output { + gamma::release(state, output, control); + } + } +} diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 704240b..b9ea219 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod activation; mod compositor; +pub(crate) mod gamma_control; mod input_method; mod layer_shell; pub(crate) mod screencopy; diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 33302bb..d1518e6 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -877,6 +877,9 @@ impl Ferese { .set_theme_mode(mode) .map_err(|error| CommandError::new("invalid_config", error)); } + "night-light" => { + return Ok(json!(crate::backends::direct::gamma::night_light_status(self))); + } "portal-inhibit" => { let inhibition = serde_json::from_value(args.clone()) .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 8cff647..feeaadc 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -10,6 +10,7 @@ mod effects; mod floating; mod focus_effect; mod frame_scheduler; +mod gamma; mod gestures; mod grabs; mod handlers; @@ -49,6 +50,7 @@ mod winit; mod xwayland; pub(crate) use session_environment::{SessionEnvironment, SessionPolicy, X11Environment}; +pub use state::{Ferese, RuntimeConfig}; use std::error::Error; use std::io; @@ -199,6 +201,7 @@ fn after_dispatch(state: &mut Ferese) { } fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>)) { + crate::backends::direct::gamma::sync_night_light(state); state.process_pending_dmabuf_imports(); state.update_named_cursor(std::time::Instant::now()); state.poll_theme(); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index ae2fd71..3c36733 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -403,6 +403,9 @@ fn scaled_animation_duration(duration: Duration, enabled: bool, speed: f64) -> D } pub struct Ferese { + pub(crate) gamma_dirty: bool, + pub(crate) gamma_timer_pending: bool, + pub(crate) night_light: ferese_config::night_light::Settings, pub(crate) session_lock_state: SessionLockManagerState, pub(crate) session_lock: Lock, pub(crate) lock_idle: IdleSettings, @@ -562,6 +565,7 @@ pub struct Ferese { #[derive(Clone)] pub struct RuntimeConfig { + pub(crate) night_light: ferese_config::night_light::Settings, pub(crate) activity_settings: ferese_config::activities::Settings, pub(crate) activity_matcher: ferese_config::activities::Matcher, pub(crate) lock_idle: IdleSettings, @@ -620,6 +624,7 @@ impl Ferese { let display_handle = display.handle(); crate::handlers::screencopy::init_global(&display_handle, &event_loop.handle()); + crate::handlers::gamma_control::init_global(&display_handle); crate::effects::init_global(&display_handle); crate::shell_control::init_global(&display_handle); @@ -698,6 +703,9 @@ impl Ferese { session_lock: crate::session_lock::Lock::default(), ipc_events: crate::ipc_events::Subscribers::default(), lock_idle: config.lock_idle, + night_light: config.night_light, + gamma_dirty: true, + gamma_timer_pending: false, config_source: None, config_sections: None, config_worker: None, @@ -959,6 +967,8 @@ impl Ferese { self.lock_idle = config.lock_idle; self.refresh_lock_idle_policy(); } + self.gamma_dirty |= self.night_light != config.night_light; + self.night_light = config.night_light; if scene_changed { self.advance_animations(Instant::now()); } diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index ca13445..68dc23b 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -1,6 +1,7 @@ pub mod activities; pub mod families; pub mod file; +pub mod night_light; pub mod notifications; pub use ferese_panel::panel; pub mod presets; diff --git a/crates/ferese-config/src/night_light.rs b/crates/ferese-config/src/night_light.rs new file mode 100644 index 0000000..0aa8345 --- /dev/null +++ b/crates/ferese-config/src/night_light.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub enabled: bool, + pub temperature: u16, +} + +impl Default for Settings { + fn default() -> Self { + Self { + enabled: false, + temperature: 4500, + } + } +} + +impl Settings { + pub fn validate(self) -> Result { + if !(1000..=6500).contains(&self.temperature) { + return Err("night-light temperature must be between 1000 and 6500 K".into()); + } + Ok(self) + } +} diff --git a/crates/ferese-ipc/src/lib.rs b/crates/ferese-ipc/src/lib.rs index 99811f7..61731b3 100644 --- a/crates/ferese-ipc/src/lib.rs +++ b/crates/ferese-ipc/src/lib.rs @@ -10,6 +10,7 @@ use serde_json::Value; pub mod events; pub mod media; +pub mod night_light; pub mod socket; pub mod theme; pub mod xwayland; diff --git a/crates/ferese-ipc/src/night_light.rs b/crates/ferese-ipc/src/night_light.rs new file mode 100644 index 0000000..9eeb58c --- /dev/null +++ b/crates/ferese-ipc/src/night_light.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct Status { + pub enabled: bool, + pub temperature: u16, + pub supported_outputs: usize, + pub active_outputs: usize, + pub failed_outputs: usize, +} diff --git a/crates/ferese-panel/src/panel.rs b/crates/ferese-panel/src/panel.rs index 52bd216..8d6a58c 100644 --- a/crates/ferese-panel/src/panel.rs +++ b/crates/ferese-panel/src/panel.rs @@ -122,6 +122,7 @@ pub enum ItemKind { Media, Activities, QuickSettings, + NightLight, Network, Audio, Recording, @@ -154,6 +155,7 @@ impl ItemKind { Self::Media => "Media", Self::Activities => "Activities", Self::QuickSettings => "Quick Settings", + Self::NightLight => "Night light", Self::Network => "Network", Self::Audio => "Audio", Self::Recording => "Recording", @@ -656,6 +658,7 @@ impl Panel { let controls = vec![ item("media", Media), item("quick-settings", QuickSettings), + item("night-light", NightLight), item("network", Network), item("audio", Audio), item("recording", Recording), @@ -765,6 +768,7 @@ mod tests { "focused-window", "media", "quick-settings", + "night-light", "network", "audio", "recording", @@ -802,6 +806,7 @@ mod tests { "workspaces", "focused-window", "quick-settings", + "night-light", "recording", "clock" ] @@ -818,7 +823,7 @@ mod tests { }), all.iter().map(|item| item.id.0.as_str()).collect::>() ); - assert_eq!(items(&panel).len(), 12); + assert_eq!(items(&panel).len(), 13); } #[test] diff --git a/crates/ferese-panel/src/panel/layout.rs b/crates/ferese-panel/src/panel/layout.rs index 41d75c6..05e6e58 100644 --- a/crates/ferese-panel/src/panel/layout.rs +++ b/crates/ferese-panel/src/panel/layout.rs @@ -744,9 +744,16 @@ mod tests { #[test] fn always_overflow_is_reserved_and_unavailable_items_are_not_allocated() { let mut panel = Panel::default(); - panel.end.groups[0].items[2].overflow = OverflowPolicy::Always; + panel + .end + .groups + .iter_mut() + .flat_map(|group| &mut group.items) + .find(|item| item.id.0 == "network") + .unwrap() + .overflow = OverflowPolicy::Always; let mut measured = measurements(&panel); - measured.remove(3); + measured.retain(|item| item.id.0 != "media"); let resolution = resolve(&panel, &measured, 2000., 32.); assert!(!resolution.items.contains_key(&ItemId("media".into()))); assert_eq!(resolution.overflow, [ItemId("network".into())]); diff --git a/docs/configuration/outputs.md b/docs/configuration/outputs.md index 54eaf8b..7bf5b70 100644 --- a/docs/configuration/outputs.md +++ b/docs/configuration/outputs.md @@ -1,5 +1,46 @@ # Displays +## Color temperature + +Night light warms the display using its hardware gamma ramp. It is off by default. +Set its state and temperature in `config.kdl`: + +```kdl +night-light { + enabled #false + temperature 4500 +} +``` + +The temperature range is 1000–6500 K. Lower values are warmer. At 6500 K, the +display keeps its neutral color. +The panel's **Night light** item opens an on/off switch and temperature slider. +In Morph, the button stays beside the tabs when you expand the panel, even if +you remove the Night light item from your panel layout. Quick Settings has the +same controls. The toggle saves immediately. Releasing the slider saves its +temperature in the config. + +Night light applies to all supported connected displays, including mirror targets. +It requires a DRM session. A nested session cannot change the host display's colors. +There is no automatic schedule yet. + +### External gamma-control clients + +Ferese supports `wlr-gamma-control-unstable-v1` on DRM outputs with hardware +gamma ramps. A gamma-control client can adjust the display's color temperature. +Only one client can control each output at a time. While that client remains +connected, Ferese reapplies its gamma table after display sleep or a session +switch. Closing the client restores the previous table. + +Nested outputs and hardware without gamma-ramp support report that gamma control +is unavailable. This protocol does not provide ICC profiles or HDR color management. + +When you turn on night light, Ferese takes control of the gamma tables and sends +external controllers `failed`. These clients must request gamma control again +after you turn it off. New requests are rejected while night light is on. + +## Output profiles + Each `output-profile` needs a unique, nonempty `name` and at least one `output` entry. A profile matches when every required monitor is connected and its optional `lid-closed` condition matches. The profile with the most required diff --git a/docs/configuration/panel.md b/docs/configuration/panel.md index 56f94f9..e55c237 100644 --- a/docs/configuration/panel.md +++ b/docs/configuration/panel.md @@ -18,6 +18,11 @@ use 8-pixel margins unless your configuration overrides them. The packaged configuration includes its own geometry overrides. For the complete options and examples, see the [panel configuration guide](../panel-configuration.md). +Add a **Night light** item in the panel editor for an on/off switch and warmth +slider. In Morph, the button stays beside the tabs when the panel is expanded, +even if you remove the item. See [color temperature](outputs.md#color-temperature) +for the saved settings and display requirements. + ```kdl panel "main" { edge "top" diff --git a/docs/panel-configuration.md b/docs/panel-configuration.md index 057487b..11d3c45 100644 --- a/docs/panel-configuration.md +++ b/docs/panel-configuration.md @@ -199,6 +199,7 @@ disappears when that output has no window title to show. | Media | `media` | Full, Compact and Icon representations | | Activities | `activities` | Full, Compact and Icon representations; see [Activities configuration](configuration/activities.md) | | Quick Settings | `quick-settings` | Opens Control Center | +| Night light | `night-light` | On/off switch and color-temperature slider | | Network | `network` | Opens Wi-Fi controls | | Audio | `audio` | Opens audio controls | | Recording | `recording` | Opens recording controls or stops the active recording | diff --git a/packaging/config.kdl b/packaging/config.kdl index af0b1da..5c85965 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -181,6 +181,7 @@ panel "main" { padding 2 3 item "media" kind="media" item "quick-settings" kind="quick-settings" + item "night-light" kind="night-light" item "network" kind="network" item "audio" kind="audio" item "recording" kind="recording" diff --git a/scripts/tests/test_portal_isolated.py b/scripts/tests/test_portal_isolated.py index dea218a..548997a 100644 --- a/scripts/tests/test_portal_isolated.py +++ b/scripts/tests/test_portal_isolated.py @@ -14,7 +14,7 @@ import subprocess import tempfile import time import unittest -from session_socket import ipc_environment +from session_socket import ipc_environment, ipc_socket @unittest.skipUnless(os.environ.get("FERESE_TEST_PORTAL") == "1", "nested portal test is opt-in") @@ -95,6 +95,7 @@ class PortalTest(unittest.TestCase): launch("compositor", [str(compositor_binary), "--backend", "nested"]) sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")] until(sockets) + until(lambda: ipc_socket(runtime).is_socket()) env["WAYLAND_DISPLAY"] = str(sockets()[0]) env = ipc_environment(env) # Private bus, including the real portal frontend and permission store. diff --git a/shell/ferese-settings/src/panel_controls.rs b/shell/ferese-settings/src/panel_controls.rs index e51fc3e..06d7777 100644 --- a/shell/ferese-settings/src/panel_controls.rs +++ b/shell/ferese-settings/src/panel_controls.rs @@ -34,7 +34,7 @@ impl std::fmt::Display for StyleChoice { } } -const CATALOG: [CatalogItem; 13] = [ +const CATALOG: [CatalogItem; 14] = [ CatalogItem(ItemKind::Overview), CatalogItem(ItemKind::Workspaces { style: ferese_panel::WorkspaceStyle::Dots, @@ -43,6 +43,7 @@ const CATALOG: [CatalogItem; 13] = [ CatalogItem(ItemKind::Media), CatalogItem(ItemKind::Activities), CatalogItem(ItemKind::QuickSettings), + CatalogItem(ItemKind::NightLight), CatalogItem(ItemKind::Network), CatalogItem(ItemKind::Audio), CatalogItem(ItemKind::Recording), @@ -862,6 +863,7 @@ pub(super) fn item_icon(kind: ItemKind, tint: cosmic::iced::Color) -> widget::ic ItemKind::Media => icons::MEDIA, ItemKind::Activities => icons::FERESE, ItemKind::QuickSettings => icons::CONTROL_CENTER, + ItemKind::NightLight => icons::MOON, ItemKind::Network => icons::WIFI_FULL, ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Recording => icons::RECORD, diff --git a/shell/ferese-settings/src/panel_edit.rs b/shell/ferese-settings/src/panel_edit.rs index 3b98e39..3f3e32e 100644 --- a/shell/ferese-settings/src/panel_edit.rs +++ b/shell/ferese-settings/src/panel_edit.rs @@ -631,7 +631,9 @@ animations { speed 0.8; } apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Bottom)); let before = snapshot.item("panels.0.start").unwrap().clone(); assert_eq!(snapshot.string("panels.0.edge", ""), "bottom"); - assert!(snapshot.boolean("panels.0.end.groups.0.items.6.percentage", false)); + let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); + let battery = panels[0].item(&ItemId("battery".into())).unwrap(); + assert_eq!(battery.kind, ItemKind::Battery { percentage: true }); apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Top)); assert_eq!(snapshot.string("panels.0.edge", ""), "top"); assert_eq!(snapshot.item("panels.0.start"), Some(&before)); @@ -659,8 +661,10 @@ animations { speed 0.8; } for edit in edits { snapshot.edit(&edit).unwrap(); } - assert!(!snapshot.boolean("panels.0.end.groups.0.items.6.visible", true)); - assert!(snapshot.boolean("panels.0.end.groups.0.items.6.percentage", false)); + let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); + let battery = panels[0].item(&ItemId("battery".into())).unwrap(); + assert_eq!(battery.kind, ItemKind::Battery { percentage: true }); + assert!(!battery.visible); assert!(snapshot.source.contains("// preserved")); Snapshot::parse(snapshot.source).unwrap(); } diff --git a/shell/ferese-settings/src/panel_preview.rs b/shell/ferese-settings/src/panel_preview.rs index e9450fb..4f560a6 100644 --- a/shell/ferese-settings/src/panel_preview.rs +++ b/shell/ferese-settings/src/panel_preview.rs @@ -368,6 +368,7 @@ impl App { ItemKind::Media => icons::MEDIA, ItemKind::Activities => icons::FERESE, ItemKind::QuickSettings => icons::CONTROL_CENTER, + ItemKind::NightLight => icons::MOON, ItemKind::Network => icons::WIFI_FULL, ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Recording => icons::RECORD, diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index 3120b83..3659c8d 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -429,6 +429,7 @@ impl FereseShell { } ItemKind::Media => self.view_media_item(representation, selected, width), ItemKind::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected), + ItemKind::NightLight => self.view_status_item(status_ui::Menu::NightLight, false, selected), ItemKind::Network => self.view_status_item(status_ui::Menu::Network, false, selected), ItemKind::Audio => self.view_status_item(status_ui::Menu::Audio, false, selected), ItemKind::Recording => self.view_status_item(status_ui::Menu::Recording, false, selected), @@ -1139,16 +1140,17 @@ mod island_tests { let end = measure_zone(&panel, &panel.end, Availability::default(), true); assert_eq!( end.items.iter().map(|(id, _)| id.as_str()).collect::>(), - ["quick-settings", "recording", "clock"] + ["quick-settings", "night-light", "recording", "clock"] ); assert_eq!(end.items[1].1.x - end.items[0].1.x, 21.); - assert_eq!(end.items[2].1.x - end.items[1].1.x, if islands { 21. } else { 34. }); + assert_eq!(end.items[2].1.x - end.items[1].1.x, 21.); + assert_eq!(end.items[3].1.x - end.items[2].1.x, if islands { 21. } else { 34. }); if islands { assert_eq!( end.islands[0], Rectangle::new( (0., 0.).into(), - (108. + 2. * padding, panel.resolved_geometry().height).into() + (129. + 2. * padding, panel.resolved_geometry().height).into() ) ); } diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 361b316..ff3589c 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -165,6 +165,7 @@ struct FereseShell { activities: activities::Model, calendar_offset: i32, status_error: Option, + night_light_temperature: Option, theme_error: Option, pending_config_error: Option, menu: Option, @@ -193,6 +194,8 @@ struct OutputSurfaces { #[derive(Clone, Debug)] enum Message { + NightLightTemperature(u16), + CommitNightLightTemperature, HubOpen(window::Id, status_ui::Menu), HubClose(window::Id), HubWorkspaceScroll(window::Id, cosmic::iced::mouse::ScrollDelta), @@ -327,6 +330,7 @@ impl cosmic::Application for FereseShell { activities: activities::Model::default(), calendar_offset: 0, status_error: None, + night_light_temperature: None, theme_error: None, pending_config_error, menu: None, @@ -754,6 +758,16 @@ impl cosmic::Application for FereseShell { Task::none() } Message::CancelPower => self.close_system_modal(), + Message::NightLightTemperature(value) => { + self.night_light_temperature = Some(value); + Task::none() + } + Message::CommitNightLightTemperature => { + if let Some(value) = self.night_light_temperature.take() { + return self.update(Message::Control(status::Action::NightLightTemperature(value))); + } + Task::none() + } Message::Control(action) => { if let Some(action) = system_modal::PowerAction::from_status(action.clone()) { return self.open_system_modal(action, None); @@ -1158,6 +1172,7 @@ mod tests { activities: Default::default(), calendar_offset: 0, status_error: None, + night_light_temperature: None, theme_error: None, pending_config_error: None, menu: None, diff --git a/shell/ferese-shell/src/panel_hub.rs b/shell/ferese-shell/src/panel_hub.rs index 21344d7..dfed0fc 100644 --- a/shell/ferese-shell/src/panel_hub.rs +++ b/shell/ferese-shell/src/panel_hub.rs @@ -372,6 +372,7 @@ impl FereseShell { } pub(super) fn close_hub(&mut self, id: window::Id) -> Task { + self.night_light_temperature = None; self.transition_hub(id, Event::Close) } @@ -955,6 +956,7 @@ impl FereseShell { .width(Length::Fill); let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center); let mut tabs = tabs.push(navigation); + tabs = tabs.push(self.hub_shortcut_button(id, ItemKind::NightLight, menu, opacity)); if let Some(battery) = self.hub_battery(id, menu == Some(status_ui::Menu::Battery), opacity) { tabs = tabs.push(battery); } @@ -1059,11 +1061,26 @@ impl FereseShell { opacity: f32, ) -> Element<'_, cosmic::Action> { let action = self.hub_control_action(id, kind).expect("shortcut action"); - let primary = crate::color(self.config.theme.for_bar().text_primary); + let night_light_on = + kind == ItemKind::NightLight && self.status.night_light.as_ref().is_some_and(|status| status.enabled); + let palette = self.config.theme.for_bar(); + let primary = crate::color(if night_light_on { + palette.accent + } else { + palette.text_primary + }); motion::button( self.render, button::custom(icons::tinted(action.icon, 18, primary).opacity(opacity)) - .name(action.label) + .name(if kind == ItemKind::NightLight { + if night_light_on { + "Night light: on" + } else { + "Night light: off" + } + } else { + action.label + }) .padding([5, 3]) .width(30) .height(28) @@ -1079,6 +1096,7 @@ impl FereseShell { ItemKind::Overview => (icons::morph::WORKSPACES, None), ItemKind::Activities => (icons::morph::ACTIVITIES, Some(status_ui::Menu::Activities)), ItemKind::QuickSettings => (icons::morph::SETTINGS, Some(status_ui::Menu::System)), + ItemKind::NightLight => (ferese_theme::icons::MOON, Some(status_ui::Menu::NightLight)), ItemKind::Network => (icons::WIFI, Some(status_ui::Menu::Network)), ItemKind::Recording => ( match self.recorder.state { @@ -1134,7 +1152,7 @@ impl FereseShell { ); let width = panel.morph.expanded_width.min(available); // Built-in tabs, battery and Close keep their space through the morph. - let tabs = 5. + f64::from(self.media.snapshot.selected.is_some()) + f64::from(self.activities_configured(id)); + let tabs = 6. + f64::from(self.media.snapshot.selected.is_some()) + f64::from(self.activities_configured(id)); let battery = if self.status.battery.is_some() { battery::WIDTH + 2. } else { @@ -1151,7 +1169,10 @@ impl FereseShell { if shortcuts.len() == capacity { break; } - if matches!(item.kind, ItemKind::Overview | ItemKind::Activities) { + if matches!( + item.kind, + ItemKind::Overview | ItemKind::Activities | ItemKind::NightLight + ) { continue; } if matches!( @@ -2797,7 +2818,7 @@ mod tests { let mut shell = shell(); let id = shell.outputs[0].bar; for (playback, counts) in [ - (None, [1, 2, 2]), + (None, [0, 2, 2]), (Some(ferese_ipc::media::Playback::Playing), [0, 2, 2]), (Some(ferese_ipc::media::Playback::Paused), [0, 2, 2]), ] { @@ -2810,6 +2831,7 @@ mod tests { shell.config.panels[0].morph.expanded_width = width; let shortcuts = shell.hub_shortcuts(id); assert_eq!(shortcuts.len(), count); + assert!(!shortcuts.contains(&ItemKind::NightLight)); assert!(!shortcuts.contains(&ItemKind::Network)); assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 1); assert!(!shortcuts.contains(&ItemKind::DisplayMode)); diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index dcbbd38..5f9b286 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -4,6 +4,7 @@ mod bluetooth; mod dbus_cache; mod hardware; mod network; +mod night_light; use std::path::Path; use std::process::{Command, Stdio}; @@ -20,6 +21,7 @@ pub struct Snapshot { pub battery: Option, pub power_profiles: Option, pub brightness: Option, + pub night_light: Option, pub notifications: Option, pub poweroff: bool, pub reboot: bool, @@ -71,6 +73,8 @@ pub enum Action { Volume(u8), Mute(bool), Brightness(u8), + NightLight(bool), + NightLightTemperature(u16), Dnd(bool), Notifications, Settings, @@ -167,9 +171,13 @@ impl Service { let mut login = dbus_cache::Cache::with_wake("org.freedesktop.login1", polling.clone()); let mut audio = audio_cache::Cache::new(polling.clone()); let mut hardware = hardware::Monitor::start(polling.clone()).ok(); + let mut night_light_monitor = night_light::Monitor::start(polling.clone()).ok(); while let Some(before) = wait_for_poll(&polling) { let revision = polling.0.lock().unwrap().4; + if night_light_monitor.is_none() { + night_light_monitor = night_light::Monitor::start(polling.clone()).ok(); + } if hardware.as_ref().is_none_or(|monitor| !monitor.alive()) { hardware = hardware::Monitor::start(polling.clone()).ok(); } @@ -190,6 +198,7 @@ impl Service { battery: battery_cache.read(before, read_battery).or_else(battery), power_profiles: primary_profiles.or(fallback_profiles), brightness: brightness(), + night_light: night_light::snapshot(), poweroff: capabilities.0, reboot: capabilities.1, suspend: capabilities.2, @@ -211,6 +220,9 @@ impl Service { error: state.2.clone(), })); let retry = [ + night_light_monitor + .is_none() + .then(|| Instant::now() + Duration::from_secs(5)), network.next_retry(), bluetooth.next_retry(), battery_cache.next_retry(), @@ -610,6 +622,10 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> { "brightnessctl", &["--class=backlight", "set", &format!("{}%", value.clamp(&1, &100))], ), + Action::NightLight(enabled) => return night_light::Change::Enabled(*enabled).save(), + Action::NightLightTemperature(temperature) => { + return night_light::Change::Temperature(*temperature).save(); + } Action::Dnd(_) | Action::Notifications => return Err("Native notifications are unavailable".into()), Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => { run("powerprofilesctl", &["set", profile]) diff --git a/shell/ferese-shell/src/status/night_light.rs b/shell/ferese-shell/src/status/night_light.rs new file mode 100644 index 0000000..3b1153a --- /dev/null +++ b/shell/ferese-shell/src/status/night_light.rs @@ -0,0 +1,72 @@ +//! Compositor status and config persistence for night light, off the UI thread. +use std::sync::{Arc, mpsc}; +use std::time::Duration; + +use ferese_ipc::night_light::Status; +use serde_json::json; + +use super::{PollState, run, wake_status}; + +pub(super) fn snapshot() -> Option { + crate::compositor_ipc::call("night-light") + .ok() + .and_then(|value| serde_json::from_value(value).ok()) +} + +// Only a changed compositor status wakes the other adapters. Dropping the sender +// cancels the wait; an in-flight IPC request already has a bounded timeout. +pub(super) struct Monitor { + _stop: mpsc::Sender<()>, +} + +impl Monitor { + pub(super) fn start(wake: Arc) -> std::io::Result { + let (stop, cancelled) = mpsc::channel(); + std::thread::Builder::new() + .name("ferese-night-light-status".into()) + .spawn(move || { + let mut previous = None; + loop { + let current = snapshot(); + if current != previous { + previous = current; + wake_status(&wake); + } + match cancelled.recv_timeout(Duration::from_secs(5)) { + Err(mpsc::RecvTimeoutError::Timeout) => {} + _ => break, + } + } + })?; + Ok(Self { _stop: stop }) + } +} + +pub(super) enum Change { + Enabled(bool), + Temperature(u16), +} + +impl Change { + pub(super) fn save(self) -> Result<(), String> { + let path = ferese_config::config_path().ok_or("No configuration directory")?; + let file = ferese_config::file::ConfigFile::open(&path)?; + let mut document = ferese_config::Document::parse(file.source()).map_err(|e| e.to_string())?; + let (field, value) = match self { + Self::Enabled(value) => ("night_light.enabled", json!(value)), + Self::Temperature(value) => ("night_light.temperature", json!(value)), + }; + document.set(field, value).map_err(|e| e.to_string())?; + let settings: ferese_config::night_light::Settings = + serde_json::from_value(document.value()["night_light"].clone()).map_err(|e| e.to_string())?; + settings.validate()?; + file.replace(&document.to_string(), None, |staged| { + run( + "ferese", + &["--check-config", staged.to_str().ok_or("Invalid configuration path")?], + ) + .map(|_| ()) + })?; + crate::compositor_ipc::call("reload-config").map(|_| ()) + } +} diff --git a/shell/ferese-shell/src/status_ui/bar.rs b/shell/ferese-shell/src/status_ui/bar.rs index be9e2f6..8141bc0 100644 --- a/shell/ferese-shell/src/status_ui/bar.rs +++ b/shell/ferese-shell/src/status_ui/bar.rs @@ -174,6 +174,13 @@ fn status_label(kind: Menu, s: &Snapshot) -> String { |b| format!("Battery: {}%, {}", b.percent, b.status), ), Menu::System => "Control Center".into(), + Menu::NightLight => { + if s.night_light.as_ref().is_some_and(|s| s.enabled) { + "Night light: on".into() + } else { + "Night light: off".into() + } + } } } @@ -191,6 +198,10 @@ pub(super) fn audio_icon(volume: u8, muted: bool) -> &'static [u8] { pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) { match kind { + Menu::NightLight => ( + ferese_theme::icons::MOON, + s.night_light.as_ref().is_some_and(|s| s.enabled), + ), Menu::Network => { let Some(n) = &s.network else { return (ferese_theme::icons::WIFI_OFF, false); diff --git a/shell/ferese-shell/src/status_ui/lifecycle.rs b/shell/ferese-shell/src/status_ui/lifecycle.rs index 4dae4cc..9433954 100644 --- a/shell/ferese-shell/src/status_ui/lifecycle.rs +++ b/shell/ferese-shell/src/status_ui/lifecycle.rs @@ -149,6 +149,7 @@ impl FereseShell { } pub fn destroy_menu(&mut self) -> Task { + self.night_light_temperature = None; let tooltip = self.dismiss_workspace_tooltip(); crate::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed); let Some(menu) = self.menu.take() else { @@ -167,6 +168,7 @@ impl FereseShell { } pub fn close_menu(&mut self) -> Task { + self.night_light_temperature = None; self.refresh_media_art(false); let Some(menu) = &mut self.menu else { return Task::none(); @@ -210,6 +212,16 @@ impl FereseShell { } } Action::Brightness(v) => self.status.brightness = Some(*v), + Action::NightLight(enabled) => { + if let Some(status) = &mut self.status.night_light { + status.enabled = *enabled; + } + } + Action::NightLightTemperature(temperature) => { + if let Some(status) = &mut self.status.night_light { + status.temperature = *temperature; + } + } Action::Wifi(v) => { if let Some(n) = &mut self.status.network { n.enabled = *v; @@ -409,9 +421,11 @@ mod tests { }, }; assert!(anchor.belongs_to(&panel)); - panel.end.groups[0].items[2].visible = false; + let items = &mut panel.end.groups[0].items; + let network = items.iter().position(|item| item.id.0 == "network").unwrap(); + items[network].visible = false; assert!(!anchor.belongs_to(&panel)); - panel.end.groups[0].items.remove(2); + panel.end.groups[0].items.remove(network); assert!(!anchor.belongs_to(&panel)); panel.id = crate::panel::PanelId("other".into()); let fallback = PopoverAnchor { diff --git a/shell/ferese-shell/src/status_ui/mod.rs b/shell/ferese-shell/src/status_ui/mod.rs index 20027ab..d1acb0f 100644 --- a/shell/ferese-shell/src/status_ui/mod.rs +++ b/shell/ferese-shell/src/status_ui/mod.rs @@ -8,6 +8,7 @@ mod controls; mod lifecycle; mod media; mod network; +mod night_light; mod system; use bar::status_icon; @@ -32,6 +33,7 @@ struct MenuStyle { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Menu { + NightLight, Activities, Workspaces, Network, @@ -51,7 +53,10 @@ impl Menu { let relevant = self == Self::System || matches!( (self, &error.action), - (Self::Network, Action::Wifi(_)) + ( + Self::NightLight, + Action::NightLight(_) | Action::NightLightTemperature(_) + ) | (Self::Network, Action::Wifi(_)) | (Self::Bluetooth, Action::Bluetooth(_)) | (Self::Audio, Action::Volume(_) | Action::Mute(_)) | (Self::Battery, Action::Brightness(_) | Action::PowerProfile(_)) @@ -76,6 +81,7 @@ impl Menu { fn title(self) -> &'static str { match self { + Self::NightLight => "Night light", Self::Activities => "Activities", Self::Workspaces => "Workspaces", Self::System => "Control Center", @@ -93,6 +99,7 @@ impl Menu { fn available(self, status: &Snapshot) -> bool { match self { + Self::NightLight => true, Self::Network => status.network.is_some(), Self::Bluetooth => status.bluetooth.is_some(), Self::Audio => status.audio.is_some(), @@ -204,6 +211,7 @@ impl FereseShell { } let mut rows = match kind { Menu::System => system::view(self, rows, style, true), + Menu::NightLight => night_light::view(self, rows, style), Menu::Network => network::view(self, rows, style), Menu::Bluetooth => bluetooth::view(self, rows, style, true), Menu::Audio => audio::view(self, rows, style, false), @@ -389,6 +397,7 @@ impl FereseShell { } rows = match kind { Menu::System => system::view(self, rows, style, false), + Menu::NightLight => night_light::view(self, rows, style), Menu::Network => network::view(self, rows, style), Menu::Bluetooth => bluetooth::view(self, rows, style, false), Menu::Audio => audio::view(self, rows, style, false), diff --git a/shell/ferese-shell/src/status_ui/night_light.rs b/shell/ferese-shell/src/status_ui/night_light.rs new file mode 100644 index 0000000..aa79211 --- /dev/null +++ b/shell/ferese-shell/src/status_ui/night_light.rs @@ -0,0 +1,43 @@ +use cosmic::widget::{column, slider}; + +use super::*; + +pub(super) fn view<'a>(shell: &'a FereseShell, mut rows: MenuRows<'a>, style: MenuStyle) -> MenuRows<'a> { + let Some(status) = &shell.status.night_light else { + return rows.push(shell.render.text("Night light is unavailable").size(13)); + }; + rows = rows.push(toggle_row( + shell.render, + "Night light", + status.enabled, + Action::NightLight(!status.enabled), + style.theme, + style.opacity, + )); + let temperature = shell.night_light_temperature.unwrap_or(status.temperature); + let warmth = column![ + shell + .render + .text(format!("Color temperature · {temperature} K")) + .size(13), + slider(1000..=6500, temperature, |value| cosmic::Action::App( + Message::NightLightTemperature(value) + )) + .step(100u16) + .on_release(cosmic::Action::App(Message::CommitNightLightTemperature)), + shell.render.text("Lower temperatures are warmer").size(12), + ] + .spacing(8); + rows = rows.push(control_card(shell.render, warmth.into(), style.primary, style.opacity)); + if status.supported_outputs == 0 { + rows = rows.push(shell.render.text("No display supports night light").size(13)); + } else if status.enabled && status.failed_outputs > 0 { + rows = rows.push( + shell + .render + .text("Night light could not be applied to some displays") + .size(12), + ); + } + rows +} diff --git a/shell/ferese-shell/src/status_ui/system.rs b/shell/ferese-shell/src/status_ui/system.rs index 0a627f9..235f3ef 100644 --- a/shell/ferese-shell/src/status_ui/system.rs +++ b/shell/ferese-shell/src/status_ui/system.rs @@ -128,6 +128,7 @@ pub(super) fn view<'a>( } rows = audio::view(shell, rows, style, true); rows = battery::brightness(shell, rows, style); + rows = super::night_light::view(shell, rows, style); rows = notification_controls(shell, rows, style, true); rows }