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

+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.)]
}