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:
1 parent
9163374cdb
commit
2a400b26d0
34 files changed
+940
-20
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.)]
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
pub(crate) mod activation;
|
||||
mod compositor;
|
||||
pub(crate) mod gamma_control;
|
||||
mod input_method;
|
||||
mod layer_shell;
|
||||
pub(crate) mod screencopy;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(items(&panel).len(), 12);
|
||||
assert_eq!(items(&panel).len(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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())]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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));
|
||||
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<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 });
|
||||
assert!(!battery.visible);
|
||||
assert!(snapshot.source.contains("// preserved"));
|
||||
Snapshot::parse(snapshot.source).unwrap();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<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[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()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ struct FereseShell {
|
||||
activities: activities::Model,
|
||||
calendar_offset: i32,
|
||||
status_error: Option<status::ActionError>,
|
||||
night_light_temperature: Option<u16>,
|
||||
theme_error: Option<String>,
|
||||
pending_config_error: Option<String>,
|
||||
menu: Option<status_ui::OpenMenu>,
|
||||
@@ -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,
|
||||
|
||||
@@ -372,6 +372,7 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub(super) fn close_hub(&mut self, id: window::Id) -> Task<Message> {
|
||||
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<Message>> {
|
||||
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));
|
||||
|
||||
@@ -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<Battery>,
|
||||
pub power_profiles: Option<PowerProfiles>,
|
||||
pub brightness: Option<u8>,
|
||||
pub night_light: Option<ferese_ipc::night_light::Status>,
|
||||
pub notifications: Option<Notifications>,
|
||||
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])
|
||||
|
||||
@@ -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(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -149,6 +149,7 @@ impl FereseShell {
|
||||
}
|
||||
|
||||
pub fn destroy_menu(&mut self) -> Task<Message> {
|
||||
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<Message> {
|
||||
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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = battery::brightness(shell, rows, style);
|
||||
rows = super::night_light::view(shell, rows, style);
|
||||
rows = notification_controls(shell, rows, style, true);
|
||||
rows
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user