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;
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<Ferese>, 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<Ferese>, 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<DrmNode, Box<dyn Error
{
output.completion.retired();
output.frame_pending = false;
if output.power.needs_wake() {
output.gamma.retry_probe();
state.gamma_dirty = true;
}
output.power.presented();
if let DrmEventTime::Monotonic(timestamp) = metadata.time {
let now = monotonic_now();
@@ -2114,6 +2121,7 @@ fn create_direct_output(
tracing::info!(?crtc, connector = %connector, "initialized DRM output");
Ok(DirectOutput {
gamma: Default::default(),
internal: internal_connector(connector.interface()),
connector: connector.handle(),
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.
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::<Ferese>(global);
+3
View File
@@ -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<DaemonConfig>,
@@ -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(),
+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;
mod compositor;
pub(crate) mod gamma_control;
mod input_method;
mod layer_shell;
pub(crate) mod screencopy;
+3
View File
@@ -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()))?;
+3
View File
@@ -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();
+10
View File
@@ -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());
}