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
@@ -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