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.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-13 14:37:26 +01:00
1 parent 9163374cdb
commit 2a400b26d0
34 files changed
+940 -20

No files matched your search

+9 -1
View File
@@ -7,6 +7,7 @@ use std::time::{Duration, Instant};
pub(crate) mod capture; pub(crate) mod capture;
mod crtc_assignment; mod crtc_assignment;
pub(crate) mod gamma;
mod lid; mod lid;
mod mirror; mod mirror;
mod output_power; mod output_power;
@@ -254,6 +255,7 @@ struct DirectDevice {
} }
struct DirectOutput { struct DirectOutput {
gamma: gamma::State,
internal: bool, internal: bool,
connector: connector::Handle, connector: connector::Handle,
mode: DrmMode, mode: DrmMode,
@@ -403,8 +405,8 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
.values_mut() .values_mut()
.for_each(PresentationClock::reset_timing); .for_each(PresentationClock::reset_timing);
backend.devices.values_mut().for_each(|device| { backend.devices.values_mut().for_each(|device| {
device.drm.pause();
device.outputs.values_mut().for_each(|output| { device.outputs.values_mut().for_each(|output| {
output.gamma.suspend();
cancel_output_timers(&handle, output); cancel_output_timers(&handle, output);
output.surface.reset_buffers(); output.surface.reset_buffers();
output.primary_commit = None; output.primary_commit = None;
@@ -412,6 +414,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
output.frame_pending = false; output.frame_pending = false;
output.power.suspended(); output.power.suspended();
}); });
device.drm.pause();
}); });
} }
@@ -887,6 +890,10 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
{ {
output.completion.retired(); output.completion.retired();
output.frame_pending = false; output.frame_pending = false;
if output.power.needs_wake() {
output.gamma.retry_probe();
state.gamma_dirty = true;
}
output.power.presented(); output.power.presented();
if let DrmEventTime::Monotonic(timestamp) = metadata.time { if let DrmEventTime::Monotonic(timestamp) = metadata.time {
let now = monotonic_now(); let now = monotonic_now();
@@ -2114,6 +2121,7 @@ fn create_direct_output(
tracing::info!(?crtc, connector = %connector, "initialized DRM output"); tracing::info!(?crtc, connector = %connector, "initialized DRM output");
Ok(DirectOutput { Ok(DirectOutput {
gamma: Default::default(),
internal: internal_connector(connector.interface()), internal: internal_connector(connector.interface()),
connector: connector.handle(), connector: connector.handle(),
mode, mode,
+390
View File
@@ -0,0 +1,390 @@
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use std::io;
use std::time::{Duration, Instant};
use smithay::backend::drm::{DrmDevice, DrmDeviceFd};
use smithay::output::Output;
use smithay::reexports::drm::control::{Device as _, crtc};
use smithay::reexports::wayland_protocols_wlr::gamma_control::v1::server::zwlr_gamma_control_v1::ZwlrGammaControlV1;
use crate::Ferese;
use crate::gamma::Ramp;
use ferese_config::night_light::Settings;
use ferese_ipc::night_light::Status;
const TRANSITION_DURATION: Duration = Duration::from_secs(1);
const TRANSITION_INTERVAL: Duration = Duration::from_millis(16);
struct Transition {
from: Ramp,
started: Instant,
duration: Duration,
}
// A single owner determines both the desired ramp and who loses control on failure.
enum Source {
Original,
Client { control: ZwlrGammaControlV1, ramp: Ramp },
NightLight { temperature: u16, ramp: Ramp },
}
#[derive(Default)]
pub(super) enum State {
#[default]
Unprobed,
Unsupported,
Ready(Box<Gamma>),
}
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<Transition>,
source: Source,
suspended: bool,
modified: bool,
failed: bool,
}
impl Gamma {
fn new(drm: &DrmDevice, crtc: crtc::Handle) -> io::Result<Self> {
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<usize> {
// 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<usize> {
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());
}
}
}
@@ -208,6 +208,7 @@ fn prepare(
/// Retire a logical output only after its hardware is unavailable or confirmed disabled. /// 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) { fn retire(state: &mut Ferese, output: &mut DirectOutput, node: DrmNode, crtc: crtc::Handle) {
output.gamma = Default::default();
cancel_output_timers(&state.loop_handle, output); cancel_output_timers(&state.loop_handle, output);
if let Some(global) = output.global.take() { if let Some(global) = output.global.take() {
state.display_handle.disable_global::<Ferese>(global); state.display_handle.disable_global::<Ferese>(global);
+3
View File
@@ -45,6 +45,8 @@ pub struct Config {
#[serde(default)] #[serde(default)]
lock_screen: crate::session_lock::IdleSettings, lock_screen: crate::session_lock::IdleSettings,
#[serde(default)] #[serde(default)]
night_light: ferese_config::night_light::Settings,
#[serde(default)]
idle_inhibit: crate::idle_inhibition::Settings, idle_inhibit: crate::idle_inhibition::Settings,
#[serde(default)] #[serde(default)]
pub(crate) autostart: Vec<DaemonConfig>, pub(crate) autostart: Vec<DaemonConfig>,
@@ -141,6 +143,7 @@ impl Config {
activity_matcher: ferese_config::activities::Matcher::new(&self.activities) activity_matcher: ferese_config::activities::Matcher::new(&self.activities)
.map_err(ConfigError::Binding)?, .map_err(ConfigError::Binding)?,
lock_idle: self.lock_screen.validate()?, lock_idle: self.lock_screen.validate()?,
night_light: self.night_light.validate().map_err(ConfigError::Binding)?,
idle_inhibit: self.idle_inhibit, idle_inhibit: self.idle_inhibit,
autostart: self.autostart.clone(), autostart: self.autostart.clone(),
layout_mode: self.layout_mode(), layout_mode: self.layout_mode(),
+93
View File
@@ -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<u16>);
impl Ramp {
pub(crate) fn zeroed(size: usize) -> io::Result<Self> {
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<Self> {
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.)]
}
+97
View File
@@ -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::<Ferese, ZwlrGammaControlManagerV1, ()>(1, ());
}
impl GlobalDispatch<ZwlrGammaControlManagerV1, ()> for Ferese {
fn bind(
_state: &mut Self,
_display: &DisplayHandle,
_client: &Client,
resource: New<ZwlrGammaControlManagerV1>,
_data: &(),
data_init: &mut DataInit<'_, Self>,
) {
data_init.init(resource, ());
}
}
impl Dispatch<ZwlrGammaControlManagerV1, ()> 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<ZwlrGammaControlV1, Option<Output>> for Ferese {
fn request(
state: &mut Self,
_client: &Client,
control: &ZwlrGammaControlV1,
request: zwlr_gamma_control_v1::Request,
output: &Option<Output>,
_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<Output>,
) {
if let Some(output) = output {
gamma::release(state, output, control);
}
}
}
+1
View File
@@ -1,5 +1,6 @@
pub(crate) mod activation; pub(crate) mod activation;
mod compositor; mod compositor;
pub(crate) mod gamma_control;
mod input_method; mod input_method;
mod layer_shell; mod layer_shell;
pub(crate) mod screencopy; pub(crate) mod screencopy;
+3
View File
@@ -877,6 +877,9 @@ impl Ferese {
.set_theme_mode(mode) .set_theme_mode(mode)
.map_err(|error| CommandError::new("invalid_config", error)); .map_err(|error| CommandError::new("invalid_config", error));
} }
"night-light" => {
return Ok(json!(crate::backends::direct::gamma::night_light_status(self)));
}
"portal-inhibit" => { "portal-inhibit" => {
let inhibition = serde_json::from_value(args.clone()) let inhibition = serde_json::from_value(args.clone())
.map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?;
+3
View File
@@ -10,6 +10,7 @@ mod effects;
mod floating; mod floating;
mod focus_effect; mod focus_effect;
mod frame_scheduler; mod frame_scheduler;
mod gamma;
mod gestures; mod gestures;
mod grabs; mod grabs;
mod handlers; mod handlers;
@@ -49,6 +50,7 @@ mod winit;
mod xwayland; mod xwayland;
pub(crate) use session_environment::{SessionEnvironment, SessionPolicy, X11Environment}; pub(crate) use session_environment::{SessionEnvironment, SessionPolicy, X11Environment};
pub use state::{Ferese, RuntimeConfig};
use std::error::Error; use std::error::Error;
use std::io; 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<'_>)) { 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.process_pending_dmabuf_imports();
state.update_named_cursor(std::time::Instant::now()); state.update_named_cursor(std::time::Instant::now());
state.poll_theme(); state.poll_theme();
+10
View File
@@ -403,6 +403,9 @@ fn scaled_animation_duration(duration: Duration, enabled: bool, speed: f64) -> D
} }
pub struct Ferese { 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_state: SessionLockManagerState,
pub(crate) session_lock: Lock, pub(crate) session_lock: Lock,
pub(crate) lock_idle: IdleSettings, pub(crate) lock_idle: IdleSettings,
@@ -562,6 +565,7 @@ pub struct Ferese {
#[derive(Clone)] #[derive(Clone)]
pub struct RuntimeConfig { pub struct RuntimeConfig {
pub(crate) night_light: ferese_config::night_light::Settings,
pub(crate) activity_settings: ferese_config::activities::Settings, pub(crate) activity_settings: ferese_config::activities::Settings,
pub(crate) activity_matcher: ferese_config::activities::Matcher, pub(crate) activity_matcher: ferese_config::activities::Matcher,
pub(crate) lock_idle: IdleSettings, pub(crate) lock_idle: IdleSettings,
@@ -620,6 +624,7 @@ impl Ferese {
let display_handle = display.handle(); let display_handle = display.handle();
crate::handlers::screencopy::init_global(&display_handle, &event_loop.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::effects::init_global(&display_handle);
crate::shell_control::init_global(&display_handle); crate::shell_control::init_global(&display_handle);
@@ -698,6 +703,9 @@ impl Ferese {
session_lock: crate::session_lock::Lock::default(), session_lock: crate::session_lock::Lock::default(),
ipc_events: crate::ipc_events::Subscribers::default(), ipc_events: crate::ipc_events::Subscribers::default(),
lock_idle: config.lock_idle, lock_idle: config.lock_idle,
night_light: config.night_light,
gamma_dirty: true,
gamma_timer_pending: false,
config_source: None, config_source: None,
config_sections: None, config_sections: None,
config_worker: None, config_worker: None,
@@ -959,6 +967,8 @@ impl Ferese {
self.lock_idle = config.lock_idle; self.lock_idle = config.lock_idle;
self.refresh_lock_idle_policy(); self.refresh_lock_idle_policy();
} }
self.gamma_dirty |= self.night_light != config.night_light;
self.night_light = config.night_light;
if scene_changed { if scene_changed {
self.advance_animations(Instant::now()); self.advance_animations(Instant::now());
} }
+1
View File
@@ -1,6 +1,7 @@
pub mod activities; pub mod activities;
pub mod families; pub mod families;
pub mod file; pub mod file;
pub mod night_light;
pub mod notifications; pub mod notifications;
pub use ferese_panel::panel; pub use ferese_panel::panel;
pub mod presets; pub mod presets;
+26
View File
@@ -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<Self, String> {
if !(1000..=6500).contains(&self.temperature) {
return Err("night-light temperature must be between 1000 and 6500 K".into());
}
Ok(self)
}
}
+1
View File
@@ -10,6 +10,7 @@ use serde_json::Value;
pub mod events; pub mod events;
pub mod media; pub mod media;
pub mod night_light;
pub mod socket; pub mod socket;
pub mod theme; pub mod theme;
pub mod xwayland; pub mod xwayland;
+10
View File
@@ -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,
}
+6 -1
View File
@@ -122,6 +122,7 @@ pub enum ItemKind {
Media, Media,
Activities, Activities,
QuickSettings, QuickSettings,
NightLight,
Network, Network,
Audio, Audio,
Recording, Recording,
@@ -154,6 +155,7 @@ impl ItemKind {
Self::Media => "Media", Self::Media => "Media",
Self::Activities => "Activities", Self::Activities => "Activities",
Self::QuickSettings => "Quick Settings", Self::QuickSettings => "Quick Settings",
Self::NightLight => "Night light",
Self::Network => "Network", Self::Network => "Network",
Self::Audio => "Audio", Self::Audio => "Audio",
Self::Recording => "Recording", Self::Recording => "Recording",
@@ -656,6 +658,7 @@ impl Panel {
let controls = vec![ let controls = vec![
item("media", Media), item("media", Media),
item("quick-settings", QuickSettings), item("quick-settings", QuickSettings),
item("night-light", NightLight),
item("network", Network), item("network", Network),
item("audio", Audio), item("audio", Audio),
item("recording", Recording), item("recording", Recording),
@@ -765,6 +768,7 @@ mod tests {
"focused-window", "focused-window",
"media", "media",
"quick-settings", "quick-settings",
"night-light",
"network", "network",
"audio", "audio",
"recording", "recording",
@@ -802,6 +806,7 @@ mod tests {
"workspaces", "workspaces",
"focused-window", "focused-window",
"quick-settings", "quick-settings",
"night-light",
"recording", "recording",
"clock" "clock"
] ]
@@ -818,7 +823,7 @@ mod tests {
}), }),
all.iter().map(|item| item.id.0.as_str()).collect::<Vec<_>>() all.iter().map(|item| item.id.0.as_str()).collect::<Vec<_>>()
); );
assert_eq!(items(&panel).len(), 12); assert_eq!(items(&panel).len(), 13);
} }
#[test] #[test]
+9 -2
View File
@@ -744,9 +744,16 @@ mod tests {
#[test] #[test]
fn always_overflow_is_reserved_and_unavailable_items_are_not_allocated() { fn always_overflow_is_reserved_and_unavailable_items_are_not_allocated() {
let mut panel = Panel::default(); 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); let mut measured = measurements(&panel);
measured.remove(3); measured.retain(|item| item.id.0 != "media");
let resolution = resolve(&panel, &measured, 2000., 32.); let resolution = resolve(&panel, &measured, 2000., 32.);
assert!(!resolution.items.contains_key(&ItemId("media".into()))); assert!(!resolution.items.contains_key(&ItemId("media".into())));
assert_eq!(resolution.overflow, [ItemId("network".into())]); assert_eq!(resolution.overflow, [ItemId("network".into())]);
+41
View File
@@ -1,5 +1,46 @@
# Displays # 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` 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 entry. A profile matches when every required monitor is connected and its
optional `lid-closed` condition matches. The profile with the most required optional `lid-closed` condition matches. The profile with the most required
+5
View File
@@ -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 configuration includes its own geometry overrides. For the complete options and
examples, see the [panel configuration guide](../panel-configuration.md). 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 ```kdl
panel "main" { panel "main" {
edge "top" edge "top"
+1
View File
@@ -199,6 +199,7 @@ disappears when that output has no window title to show.
| Media | `media` | Full, Compact and Icon representations | | Media | `media` | Full, Compact and Icon representations |
| Activities | `activities` | Full, Compact and Icon representations; see [Activities configuration](configuration/activities.md) | | Activities | `activities` | Full, Compact and Icon representations; see [Activities configuration](configuration/activities.md) |
| Quick Settings | `quick-settings` | Opens Control Center | | Quick Settings | `quick-settings` | Opens Control Center |
| Night light | `night-light` | On/off switch and color-temperature slider |
| Network | `network` | Opens Wi-Fi controls | | Network | `network` | Opens Wi-Fi controls |
| Audio | `audio` | Opens audio controls | | Audio | `audio` | Opens audio controls |
| Recording | `recording` | Opens recording controls or stops the active recording | | Recording | `recording` | Opens recording controls or stops the active recording |
+1
View File
@@ -181,6 +181,7 @@ panel "main" {
padding 2 3 padding 2 3
item "media" kind="media" item "media" kind="media"
item "quick-settings" kind="quick-settings" item "quick-settings" kind="quick-settings"
item "night-light" kind="night-light"
item "network" kind="network" item "network" kind="network"
item "audio" kind="audio" item "audio" kind="audio"
item "recording" kind="recording" item "recording" kind="recording"
+2 -1
View File
@@ -14,7 +14,7 @@ import subprocess
import tempfile import tempfile
import time import time
import unittest 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") @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"]) launch("compositor", [str(compositor_binary), "--backend", "nested"])
sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")] sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")]
until(sockets) until(sockets)
until(lambda: ipc_socket(runtime).is_socket())
env["WAYLAND_DISPLAY"] = str(sockets()[0]) env["WAYLAND_DISPLAY"] = str(sockets()[0])
env = ipc_environment(env) env = ipc_environment(env)
# Private bus, including the real portal frontend and permission store. # Private bus, including the real portal frontend and permission store.
+3 -1
View File
@@ -34,7 +34,7 @@ impl std::fmt::Display for StyleChoice {
} }
} }
const CATALOG: [CatalogItem; 13] = [ const CATALOG: [CatalogItem; 14] = [
CatalogItem(ItemKind::Overview), CatalogItem(ItemKind::Overview),
CatalogItem(ItemKind::Workspaces { CatalogItem(ItemKind::Workspaces {
style: ferese_panel::WorkspaceStyle::Dots, style: ferese_panel::WorkspaceStyle::Dots,
@@ -43,6 +43,7 @@ const CATALOG: [CatalogItem; 13] = [
CatalogItem(ItemKind::Media), CatalogItem(ItemKind::Media),
CatalogItem(ItemKind::Activities), CatalogItem(ItemKind::Activities),
CatalogItem(ItemKind::QuickSettings), CatalogItem(ItemKind::QuickSettings),
CatalogItem(ItemKind::NightLight),
CatalogItem(ItemKind::Network), CatalogItem(ItemKind::Network),
CatalogItem(ItemKind::Audio), CatalogItem(ItemKind::Audio),
CatalogItem(ItemKind::Recording), 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::Media => icons::MEDIA,
ItemKind::Activities => icons::FERESE, ItemKind::Activities => icons::FERESE,
ItemKind::QuickSettings => icons::CONTROL_CENTER, ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::NightLight => icons::MOON,
ItemKind::Network => icons::WIFI_FULL, ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Audio => icons::VOLUME_HIGH,
ItemKind::Recording => icons::RECORD, ItemKind::Recording => icons::RECORD,
+7 -3
View File
@@ -631,7 +631,9 @@ animations { speed 0.8; }
apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Bottom)); apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Bottom));
let before = snapshot.item("panels.0.start").unwrap().clone(); let before = snapshot.item("panels.0.start").unwrap().clone();
assert_eq!(snapshot.string("panels.0.edge", ""), "bottom"); assert_eq!(snapshot.string("panels.0.edge", ""), "bottom");
assert!(snapshot.boolean("panels.0.end.groups.0.items.6.percentage", false)); let panels: Vec<Panel> = 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)); apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Top));
assert_eq!(snapshot.string("panels.0.edge", ""), "top"); assert_eq!(snapshot.string("panels.0.edge", ""), "top");
assert_eq!(snapshot.item("panels.0.start"), Some(&before)); assert_eq!(snapshot.item("panels.0.start"), Some(&before));
@@ -659,8 +661,10 @@ animations { speed 0.8; }
for edit in edits { for edit in edits {
snapshot.edit(&edit).unwrap(); snapshot.edit(&edit).unwrap();
} }
assert!(!snapshot.boolean("panels.0.end.groups.0.items.6.visible", true)); let panels: Vec<Panel> = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap();
assert!(snapshot.boolean("panels.0.end.groups.0.items.6.percentage", false)); 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")); assert!(snapshot.source.contains("// preserved"));
Snapshot::parse(snapshot.source).unwrap(); Snapshot::parse(snapshot.source).unwrap();
} }
@@ -368,6 +368,7 @@ impl App {
ItemKind::Media => icons::MEDIA, ItemKind::Media => icons::MEDIA,
ItemKind::Activities => icons::FERESE, ItemKind::Activities => icons::FERESE,
ItemKind::QuickSettings => icons::CONTROL_CENTER, ItemKind::QuickSettings => icons::CONTROL_CENTER,
ItemKind::NightLight => icons::MOON,
ItemKind::Network => icons::WIFI_FULL, ItemKind::Network => icons::WIFI_FULL,
ItemKind::Audio => icons::VOLUME_HIGH, ItemKind::Audio => icons::VOLUME_HIGH,
ItemKind::Recording => icons::RECORD, ItemKind::Recording => icons::RECORD,
+5 -3
View File
@@ -429,6 +429,7 @@ impl FereseShell {
} }
ItemKind::Media => self.view_media_item(representation, selected, width), ItemKind::Media => self.view_media_item(representation, selected, width),
ItemKind::QuickSettings => self.view_status_item(status_ui::Menu::System, false, selected), 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::Network => self.view_status_item(status_ui::Menu::Network, false, selected),
ItemKind::Audio => self.view_status_item(status_ui::Menu::Audio, 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), 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); let end = measure_zone(&panel, &panel.end, Availability::default(), true);
assert_eq!( assert_eq!(
end.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), end.items.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(),
["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[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 { if islands {
assert_eq!( assert_eq!(
end.islands[0], end.islands[0],
Rectangle::new( Rectangle::new(
(0., 0.).into(), (0., 0.).into(),
(108. + 2. * padding, panel.resolved_geometry().height).into() (129. + 2. * padding, panel.resolved_geometry().height).into()
) )
); );
} }
+15
View File
@@ -165,6 +165,7 @@ struct FereseShell {
activities: activities::Model, activities: activities::Model,
calendar_offset: i32, calendar_offset: i32,
status_error: Option<status::ActionError>, status_error: Option<status::ActionError>,
night_light_temperature: Option<u16>,
theme_error: Option<String>, theme_error: Option<String>,
pending_config_error: Option<String>, pending_config_error: Option<String>,
menu: Option<status_ui::OpenMenu>, menu: Option<status_ui::OpenMenu>,
@@ -193,6 +194,8 @@ struct OutputSurfaces {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { enum Message {
NightLightTemperature(u16),
CommitNightLightTemperature,
HubOpen(window::Id, status_ui::Menu), HubOpen(window::Id, status_ui::Menu),
HubClose(window::Id), HubClose(window::Id),
HubWorkspaceScroll(window::Id, cosmic::iced::mouse::ScrollDelta), HubWorkspaceScroll(window::Id, cosmic::iced::mouse::ScrollDelta),
@@ -327,6 +330,7 @@ impl cosmic::Application for FereseShell {
activities: activities::Model::default(), activities: activities::Model::default(),
calendar_offset: 0, calendar_offset: 0,
status_error: None, status_error: None,
night_light_temperature: None,
theme_error: None, theme_error: None,
pending_config_error, pending_config_error,
menu: None, menu: None,
@@ -754,6 +758,16 @@ impl cosmic::Application for FereseShell {
Task::none() Task::none()
} }
Message::CancelPower => self.close_system_modal(), 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) => { Message::Control(action) => {
if let Some(action) = system_modal::PowerAction::from_status(action.clone()) { if let Some(action) = system_modal::PowerAction::from_status(action.clone()) {
return self.open_system_modal(action, None); return self.open_system_modal(action, None);
@@ -1158,6 +1172,7 @@ mod tests {
activities: Default::default(), activities: Default::default(),
calendar_offset: 0, calendar_offset: 0,
status_error: None, status_error: None,
night_light_temperature: None,
theme_error: None, theme_error: None,
pending_config_error: None, pending_config_error: None,
menu: None, menu: None,
+27 -5
View File
@@ -372,6 +372,7 @@ impl FereseShell {
} }
pub(super) fn close_hub(&mut self, id: window::Id) -> Task<Message> { pub(super) fn close_hub(&mut self, id: window::Id) -> Task<Message> {
self.night_light_temperature = None;
self.transition_hub(id, Event::Close) self.transition_hub(id, Event::Close)
} }
@@ -955,6 +956,7 @@ impl FereseShell {
.width(Length::Fill); .width(Length::Fill);
let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center); let tabs = row::with_capacity(2).spacing(2).align_y(Alignment::Center);
let mut tabs = tabs.push(navigation); 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) { if let Some(battery) = self.hub_battery(id, menu == Some(status_ui::Menu::Battery), opacity) {
tabs = tabs.push(battery); tabs = tabs.push(battery);
} }
@@ -1059,11 +1061,26 @@ impl FereseShell {
opacity: f32, opacity: f32,
) -> Element<'_, cosmic::Action<Message>> { ) -> Element<'_, cosmic::Action<Message>> {
let action = self.hub_control_action(id, kind).expect("shortcut 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( motion::button(
self.render, self.render,
button::custom(icons::tinted(action.icon, 18, primary).opacity(opacity)) 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]) .padding([5, 3])
.width(30) .width(30)
.height(28) .height(28)
@@ -1079,6 +1096,7 @@ impl FereseShell {
ItemKind::Overview => (icons::morph::WORKSPACES, None), ItemKind::Overview => (icons::morph::WORKSPACES, None),
ItemKind::Activities => (icons::morph::ACTIVITIES, Some(status_ui::Menu::Activities)), ItemKind::Activities => (icons::morph::ACTIVITIES, Some(status_ui::Menu::Activities)),
ItemKind::QuickSettings => (icons::morph::SETTINGS, Some(status_ui::Menu::System)), 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::Network => (icons::WIFI, Some(status_ui::Menu::Network)),
ItemKind::Recording => ( ItemKind::Recording => (
match self.recorder.state { match self.recorder.state {
@@ -1134,7 +1152,7 @@ impl FereseShell {
); );
let width = panel.morph.expanded_width.min(available); let width = panel.morph.expanded_width.min(available);
// Built-in tabs, battery and Close keep their space through the morph. // 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() { let battery = if self.status.battery.is_some() {
battery::WIDTH + 2. battery::WIDTH + 2.
} else { } else {
@@ -1151,7 +1169,10 @@ impl FereseShell {
if shortcuts.len() == capacity { if shortcuts.len() == capacity {
break; break;
} }
if matches!(item.kind, ItemKind::Overview | ItemKind::Activities) { if matches!(
item.kind,
ItemKind::Overview | ItemKind::Activities | ItemKind::NightLight
) {
continue; continue;
} }
if matches!( if matches!(
@@ -2797,7 +2818,7 @@ mod tests {
let mut shell = shell(); let mut shell = shell();
let id = shell.outputs[0].bar; let id = shell.outputs[0].bar;
for (playback, counts) in [ 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::Playing), [0, 2, 2]),
(Some(ferese_ipc::media::Playback::Paused), [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; shell.config.panels[0].morph.expanded_width = width;
let shortcuts = shell.hub_shortcuts(id); let shortcuts = shell.hub_shortcuts(id);
assert_eq!(shortcuts.len(), count); assert_eq!(shortcuts.len(), count);
assert!(!shortcuts.contains(&ItemKind::NightLight));
assert!(!shortcuts.contains(&ItemKind::Network)); assert!(!shortcuts.contains(&ItemKind::Network));
assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 1); assert_eq!(shortcuts.contains(&ItemKind::Recording), count > 1);
assert!(!shortcuts.contains(&ItemKind::DisplayMode)); assert!(!shortcuts.contains(&ItemKind::DisplayMode));
+16
View File
@@ -4,6 +4,7 @@ mod bluetooth;
mod dbus_cache; mod dbus_cache;
mod hardware; mod hardware;
mod network; mod network;
mod night_light;
use std::path::Path; use std::path::Path;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
@@ -20,6 +21,7 @@ pub struct Snapshot {
pub battery: Option<Battery>, pub battery: Option<Battery>,
pub power_profiles: Option<PowerProfiles>, pub power_profiles: Option<PowerProfiles>,
pub brightness: Option<u8>, pub brightness: Option<u8>,
pub night_light: Option<ferese_ipc::night_light::Status>,
pub notifications: Option<Notifications>, pub notifications: Option<Notifications>,
pub poweroff: bool, pub poweroff: bool,
pub reboot: bool, pub reboot: bool,
@@ -71,6 +73,8 @@ pub enum Action {
Volume(u8), Volume(u8),
Mute(bool), Mute(bool),
Brightness(u8), Brightness(u8),
NightLight(bool),
NightLightTemperature(u16),
Dnd(bool), Dnd(bool),
Notifications, Notifications,
Settings, Settings,
@@ -167,9 +171,13 @@ impl Service {
let mut login = dbus_cache::Cache::with_wake("org.freedesktop.login1", polling.clone()); let mut login = dbus_cache::Cache::with_wake("org.freedesktop.login1", polling.clone());
let mut audio = audio_cache::Cache::new(polling.clone()); let mut audio = audio_cache::Cache::new(polling.clone());
let mut hardware = hardware::Monitor::start(polling.clone()).ok(); 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) { while let Some(before) = wait_for_poll(&polling) {
let revision = polling.0.lock().unwrap().4; 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()) { if hardware.as_ref().is_none_or(|monitor| !monitor.alive()) {
hardware = hardware::Monitor::start(polling.clone()).ok(); hardware = hardware::Monitor::start(polling.clone()).ok();
} }
@@ -190,6 +198,7 @@ impl Service {
battery: battery_cache.read(before, read_battery).or_else(battery), battery: battery_cache.read(before, read_battery).or_else(battery),
power_profiles: primary_profiles.or(fallback_profiles), power_profiles: primary_profiles.or(fallback_profiles),
brightness: brightness(), brightness: brightness(),
night_light: night_light::snapshot(),
poweroff: capabilities.0, poweroff: capabilities.0,
reboot: capabilities.1, reboot: capabilities.1,
suspend: capabilities.2, suspend: capabilities.2,
@@ -211,6 +220,9 @@ impl Service {
error: state.2.clone(), error: state.2.clone(),
})); }));
let retry = [ let retry = [
night_light_monitor
.is_none()
.then(|| Instant::now() + Duration::from_secs(5)),
network.next_retry(), network.next_retry(),
bluetooth.next_retry(), bluetooth.next_retry(),
battery_cache.next_retry(), battery_cache.next_retry(),
@@ -610,6 +622,10 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
"brightnessctl", "brightnessctl",
&["--class=backlight", "set", &format!("{}%", value.clamp(&1, &100))], &["--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::Dnd(_) | Action::Notifications => return Err("Native notifications are unavailable".into()),
Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => { Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => {
run("powerprofilesctl", &["set", profile]) run("powerprofilesctl", &["set", profile])
@@ -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<Status> {
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<PollState>) -> std::io::Result<Self> {
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(|_| ())
}
}
+11
View File
@@ -174,6 +174,13 @@ fn status_label(kind: Menu, s: &Snapshot) -> String {
|b| format!("Battery: {}%, {}", b.percent, b.status), |b| format!("Battery: {}%, {}", b.percent, b.status),
), ),
Menu::System => "Control Center".into(), 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) { pub(super) fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
match kind { match kind {
Menu::NightLight => (
ferese_theme::icons::MOON,
s.night_light.as_ref().is_some_and(|s| s.enabled),
),
Menu::Network => { Menu::Network => {
let Some(n) = &s.network else { let Some(n) = &s.network else {
return (ferese_theme::icons::WIFI_OFF, false); return (ferese_theme::icons::WIFI_OFF, false);
+16 -2
View File
@@ -149,6 +149,7 @@ impl FereseShell {
} }
pub fn destroy_menu(&mut self) -> Task<Message> { pub fn destroy_menu(&mut self) -> Task<Message> {
self.night_light_temperature = None;
let tooltip = self.dismiss_workspace_tooltip(); let tooltip = self.dismiss_workspace_tooltip();
crate::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed); crate::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed);
let Some(menu) = self.menu.take() else { let Some(menu) = self.menu.take() else {
@@ -167,6 +168,7 @@ impl FereseShell {
} }
pub fn close_menu(&mut self) -> Task<Message> { pub fn close_menu(&mut self) -> Task<Message> {
self.night_light_temperature = None;
self.refresh_media_art(false); self.refresh_media_art(false);
let Some(menu) = &mut self.menu else { let Some(menu) = &mut self.menu else {
return Task::none(); return Task::none();
@@ -210,6 +212,16 @@ impl FereseShell {
} }
} }
Action::Brightness(v) => self.status.brightness = Some(*v), 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) => { Action::Wifi(v) => {
if let Some(n) = &mut self.status.network { if let Some(n) = &mut self.status.network {
n.enabled = *v; n.enabled = *v;
@@ -409,9 +421,11 @@ mod tests {
}, },
}; };
assert!(anchor.belongs_to(&panel)); 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)); assert!(!anchor.belongs_to(&panel));
panel.end.groups[0].items.remove(2); panel.end.groups[0].items.remove(network);
assert!(!anchor.belongs_to(&panel)); assert!(!anchor.belongs_to(&panel));
panel.id = crate::panel::PanelId("other".into()); panel.id = crate::panel::PanelId("other".into());
let fallback = PopoverAnchor { let fallback = PopoverAnchor {
+10 -1
View File
@@ -8,6 +8,7 @@ mod controls;
mod lifecycle; mod lifecycle;
mod media; mod media;
mod network; mod network;
mod night_light;
mod system; mod system;
use bar::status_icon; use bar::status_icon;
@@ -32,6 +33,7 @@ struct MenuStyle {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Menu { pub enum Menu {
NightLight,
Activities, Activities,
Workspaces, Workspaces,
Network, Network,
@@ -51,7 +53,10 @@ impl Menu {
let relevant = self == Self::System let relevant = self == Self::System
|| matches!( || matches!(
(self, &error.action), (self, &error.action),
(Self::Network, Action::Wifi(_)) (
Self::NightLight,
Action::NightLight(_) | Action::NightLightTemperature(_)
) | (Self::Network, Action::Wifi(_))
| (Self::Bluetooth, Action::Bluetooth(_)) | (Self::Bluetooth, Action::Bluetooth(_))
| (Self::Audio, Action::Volume(_) | Action::Mute(_)) | (Self::Audio, Action::Volume(_) | Action::Mute(_))
| (Self::Battery, Action::Brightness(_) | Action::PowerProfile(_)) | (Self::Battery, Action::Brightness(_) | Action::PowerProfile(_))
@@ -76,6 +81,7 @@ impl Menu {
fn title(self) -> &'static str { fn title(self) -> &'static str {
match self { match self {
Self::NightLight => "Night light",
Self::Activities => "Activities", Self::Activities => "Activities",
Self::Workspaces => "Workspaces", Self::Workspaces => "Workspaces",
Self::System => "Control Center", Self::System => "Control Center",
@@ -93,6 +99,7 @@ impl Menu {
fn available(self, status: &Snapshot) -> bool { fn available(self, status: &Snapshot) -> bool {
match self { match self {
Self::NightLight => true,
Self::Network => status.network.is_some(), Self::Network => status.network.is_some(),
Self::Bluetooth => status.bluetooth.is_some(), Self::Bluetooth => status.bluetooth.is_some(),
Self::Audio => status.audio.is_some(), Self::Audio => status.audio.is_some(),
@@ -204,6 +211,7 @@ impl FereseShell {
} }
let mut rows = match kind { let mut rows = match kind {
Menu::System => system::view(self, rows, style, true), Menu::System => system::view(self, rows, style, true),
Menu::NightLight => night_light::view(self, rows, style),
Menu::Network => network::view(self, rows, style), Menu::Network => network::view(self, rows, style),
Menu::Bluetooth => bluetooth::view(self, rows, style, true), Menu::Bluetooth => bluetooth::view(self, rows, style, true),
Menu::Audio => audio::view(self, rows, style, false), Menu::Audio => audio::view(self, rows, style, false),
@@ -389,6 +397,7 @@ impl FereseShell {
} }
rows = match kind { rows = match kind {
Menu::System => system::view(self, rows, style, false), Menu::System => system::view(self, rows, style, false),
Menu::NightLight => night_light::view(self, rows, style),
Menu::Network => network::view(self, rows, style), Menu::Network => network::view(self, rows, style),
Menu::Bluetooth => bluetooth::view(self, rows, style, false), Menu::Bluetooth => bluetooth::view(self, rows, style, false),
Menu::Audio => audio::view(self, rows, style, false), Menu::Audio => audio::view(self, rows, style, false),
@@ -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
}
@@ -128,6 +128,7 @@ pub(super) fn view<'a>(
} }
rows = audio::view(shell, rows, style, true); rows = audio::view(shell, rows, style, true);
rows = battery::brightness(shell, rows, style); rows = battery::brightness(shell, rows, style);
rows = super::night_light::view(shell, rows, style);
rows = notification_controls(shell, rows, style, true); rows = notification_controls(shell, rows, style, true);
rows rows
} }