system settings
git clone https://git.lucas.co/cce-system-interface.git
Power: an Animations lever per power mode
Enabled / Disabled / Not set, like Turbo, so Power Saver can turn the
DE's animations off on unplug. cce-power-apply records it by writing
on/off to cce_ui::motion::STATE_PATH (/run/cce/animations), which the
compositor and every cce-ui client follow; under /run because the
writer runs as root from udev with no session and no $HOME.
The stale-helper probe now also requires the helper's usage to list
every lever: one that knew modes but predated a lever passed it, took
the pkexec prompt and rejected that pick with nothing said.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/pages/power.rs | 57 ++++++++++++++++++++++++++++++++++++------
src/power_plan.rs | 73 +++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 108 insertions(+), 22 deletions(-)
diff --git a/src/pages/power.rs b/src/pages/power.rs
index dba65c2..e771d38 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -10,6 +10,12 @@
//! charge at 80% is the classic battery-longevity lever
//! - `/sys/devices/system/cpu/intel_pstate/no_turbo` — turbo boost
//!
+//! One lever is not a hardware interface: **Animations**, which the helper
+//! records at `cce_ui::motion::STATE_PATH` for the compositor and every
+//! cce-ui client to follow. It is per mode for the same reason the others
+//! are — easing every frame costs power, so it belongs with what changes on
+//! unplug — and it is always offered, since every host has animations.
+//!
//! The page is three sections. **Battery** is the pack itself: its facts and
//! the charge limit, which is a charging policy and so belongs to no mode.
//! **Power Mode** edits one mode's levers, chosen by the dropdown at the top
@@ -98,6 +104,9 @@ pub struct PowerFacts {
pub gpu_limit_w: Option<u32>,
pub gpu_default_w: Option<u32>,
pub gpu_min_w: Option<u32>,
+ /// Whether the session animates right now: the helper's state file, or
+ /// on when there is none — which is what the toolkit and compositor do.
+ pub animations: bool,
}
@@ -371,6 +380,8 @@ pub async fn fetch_power_state() -> PowerFacts {
}
}
+ f.animations = cce_ui::motion::read_state().unwrap_or(true);
+
f.hda_idle_secs =
read_trim("/sys/module/snd_hda_intel/parameters/power_save").and_then(|s| s.parse().ok());
@@ -417,6 +428,9 @@ fn choices(lever: Lever, f: &PowerFacts) -> Vec<(String, String)> {
Lever::Epp => tokens(&f.epps),
Lever::Governor => tokens(&f.governors),
Lever::Aspm => tokens(&f.aspm_policies),
+ Lever::Animations => {
+ vec![("on".to_string(), "Enabled".to_string()), ("off".to_string(), "Disabled".to_string())]
+ }
Lever::Turbo => {
if f.turbo.is_some() {
vec![("on".to_string(), "Enabled".to_string()), ("off".to_string(), "Disabled".to_string())]
@@ -470,6 +484,7 @@ fn live(lever: Lever, f: &PowerFacts) -> Option<String> {
Lever::IgpuMaxMhz => f.igpu_mhz.map(|v| v.to_string()),
Lever::AudioIdleSecs => f.hda_idle_secs.map(|v| v.to_string()),
Lever::GpuLimitW => f.gpu_limit_w.map(|v| v.to_string()),
+ Lever::Animations => Some(if f.animations { "on" } else { "off" }.to_string()),
}
}
@@ -478,7 +493,7 @@ fn live(lever: Lever, f: &PowerFacts) -> Option<String> {
/// silently matching the wrong one).
fn display_of(lever: Lever, value: &str) -> String {
match lever {
- Lever::Turbo => if value == "on" { "Enabled" } else { "Disabled" }.to_string(),
+ Lever::Turbo | Lever::Animations => if value == "on" { "Enabled" } else { "Disabled" }.to_string(),
Lever::IgpuMaxMhz => format!("{} MHz", value),
Lever::AudioIdleSecs => {
if value == "0" { "Never suspend".to_string() } else { format!("After {} s idle", value) }
@@ -500,6 +515,7 @@ fn set_live(lever: Lever, value: &str, f: &mut PowerFacts) {
Lever::IgpuMaxMhz => f.igpu_mhz = value.parse().ok(),
Lever::AudioIdleSecs => f.hda_idle_secs = value.parse().ok(),
Lever::GpuLimitW => f.gpu_limit_w = value.parse().ok(),
+ Lever::Animations => f.animations = value == "on",
}
}
@@ -971,6 +987,7 @@ mod tests {
aspm_policies: vec!["default".into(), "performance".into(), "powersave".into()],
aspm: "default".to_string(),
hda_idle_secs: Some(10),
+ animations: true,
}
}
@@ -989,6 +1006,7 @@ mod tests {
const TURBO: usize = 3;
const IGPU: usize = 4;
const AUDIO: usize = 6;
+ const ANIMATIONS: usize = 8;
#[test]
fn the_lever_section_shows_the_edited_mode_behind_not_set() {
@@ -1146,32 +1164,32 @@ mod tests {
rebuild_options(&mut st);
let counts = |st: &mut PowerState| st.section_widgets().iter().map(Vec::len).collect::<Vec<_>>();
// Every interface present: the charge limit, the mode picker plus all
- // eight levers, and one assignment per adapter state.
- assert_eq!(counts(&mut st), [1, 9, 2]);
+ // nine levers, and one assignment per adapter state.
+ assert_eq!(counts(&mut st), [1, 10, 2]);
// A host without a charge-limit knob or turbo file reports fewer.
st.facts.charge_limit = None;
st.facts.turbo = None;
rebuild_options(&mut st);
- assert_eq!(counts(&mut st), [0, 8, 2]);
+ assert_eq!(counts(&mut st), [0, 9, 2]);
// No cpufreq governors and no NVIDIA driver: both drop out too.
st.facts.governors.clear();
st.facts.gpu_limit_w = None;
st.facts.gpu_default_w = None;
st.facts.gpu_min_w = None;
rebuild_options(&mut st);
- assert_eq!(counts(&mut st), [0, 6, 2]);
+ assert_eq!(counts(&mut st), [0, 7, 2]);
// No Intel render clocks, an ASPM-less kernel and no snd_hda_intel is
- // down to profile and epp.
+ // down to profile, epp and animations — which no host lacks.
st.facts.igpu_max_mhz = None;
st.facts.igpu_min_mhz = None;
st.facts.aspm_policies.clear();
st.facts.hda_idle_secs = None;
rebuild_options(&mut st);
- assert_eq!(counts(&mut st), [0, 3, 2]);
+ assert_eq!(counts(&mut st), [0, 4, 2]);
// A desktop: one adapter state, so there is nothing to assign.
st.facts.battery_present = false;
rebuild_options(&mut st);
- assert_eq!(counts(&mut st), [0, 3]);
+ assert_eq!(counts(&mut st), [0, 4]);
}
#[test]
@@ -1274,6 +1292,27 @@ mod tests {
assert!(lines(&mut st).iter().any(|l| l.contains("not installed")));
}
+ #[test]
+ fn animations_is_offered_everywhere_and_reports_the_live_state() {
+ let mut st = loaded();
+ assert_eq!(st.levers.rows[ANIMATIONS], ["", "on", "off"]);
+ assert_eq!(st.levers.dds[ANIMATIONS].options, ["Not set — now Enabled", "Enabled", "Disabled"]);
+ assert_eq!(st.levers.dds[ANIMATIONS].selected, 0);
+ // A host with none of the hardware levers still has animations.
+ st.facts = PowerFacts {
+ plan: st.facts.plan.clone(),
+ source: Source::Battery,
+ animations: false,
+ ..PowerFacts::default()
+ };
+ rebuild_options(&mut st);
+ assert_eq!(st.levers.dds[ANIMATIONS].options[0], "Not set — now Disabled");
+ // A planned value is selected like any other lever's.
+ st.facts.plan.put(Mode::PowerSaver, Lever::Animations, Some("off")).unwrap();
+ rebuild_options(&mut st);
+ assert_eq!(st.levers.dds[ANIMATIONS].selected, 2);
+ }
+
#[test]
fn set_live_mirrors_each_lever() {
let mut f = facts();
@@ -1281,6 +1320,7 @@ mod tests {
set_live(Lever::Turbo, "off", &mut f);
set_live(Lever::IgpuMaxMhz, "800", &mut f);
set_live(Lever::GpuLimitW, "40", &mut f);
+ set_live(Lever::Animations, "off", &mut f);
assert_eq!(f.profile, "performance");
assert_eq!(f.turbo, Some(false));
assert_eq!(f.igpu_mhz, Some(800));
@@ -1288,5 +1328,6 @@ mod tests {
// Round trip: what set_live wrote is what live() reads back.
assert_eq!(live(Lever::Turbo, &f).as_deref(), Some("off"));
assert_eq!(live(Lever::GpuLimitW, &f).as_deref(), Some("40"));
+ assert_eq!(live(Lever::Animations, &f).as_deref(), Some("off"));
}
}
diff --git a/src/power_plan.rs b/src/power_plan.rs
index bc9cfeb..0762b1d 100644
--- a/src/power_plan.rs
+++ b/src/power_plan.rs
@@ -139,9 +139,10 @@ pub enum Lever {
Aspm,
AudioIdleSecs,
GpuLimitW,
+ Animations,
}
impl Lever {
- pub const ALL: [Lever; 8] = [
+ pub const ALL: [Lever; 9] = [
Lever::Profile,
Lever::Epp,
Lever::Governor,
@@ -150,6 +151,7 @@ impl Lever {
Lever::Aspm,
Lever::AudioIdleSecs,
Lever::GpuLimitW,
+ Lever::Animations,
];
/// The node name in the plan file, and the CLI spelling.
@@ -163,6 +165,7 @@ impl Lever {
Lever::Aspm => "aspm",
Lever::AudioIdleSecs => "audio_idle_secs",
Lever::GpuLimitW => "gpu_limit_w",
+ Lever::Animations => "animations",
}
}
@@ -180,6 +183,7 @@ impl Lever {
Lever::Aspm => "PCIe Power Management",
Lever::AudioIdleSecs => "Audio Codec Idle",
Lever::GpuLimitW => "GPU Power Limit",
+ Lever::Animations => "Animations",
}
}
@@ -189,12 +193,13 @@ impl Lever {
}
/// Shape check on a value before it goes anywhere near sysfs or a shell
- /// line: a sysfs token, an unsigned integer, or on/off for turbo. This is
+ /// line: a sysfs token, an unsigned integer, or on/off for turbo and
+ /// animations. This is
/// the guard `set` and `apply` share; range checks against the hardware
/// happen in [`apply_lever`], where the ranges can be read.
pub fn value_ok(self, v: &str) -> bool {
match self {
- Lever::Turbo => v == "on" || v == "off",
+ Lever::Turbo | Lever::Animations => v == "on" || v == "off",
l if l.is_numeric() => v.parse::<u32>().is_ok(),
_ => sysfs_token_ok(v),
}
@@ -442,14 +447,15 @@ pub enum Automation {
/// is only applied when the page itself changes a lever.
#[default]
Missing,
- /// Both installed, but the helper predates modes: it cannot parse a
- /// plan with `mode` blocks, so it applies nothing on plug or unplug —
- /// and it rejects the page's own `set`/`assign` calls too.
+ /// Both installed, but the helper predates modes or one of the levers:
+ /// it cannot parse a plan that uses them, so it applies nothing on plug
+ /// or unplug — and it rejects the page's own `set`/`assign` calls too.
Stale,
Ready,
}
-/// Whether a helper binary speaks the current, mode-shaped CLI.
+/// Whether a helper binary speaks the current CLI: modes, and every lever
+/// this page can send it.
///
/// Asked by running it with no arguments, which prints its usage and exits
/// 2 without touching anything. Deliberately NOT a string search inside the
@@ -459,17 +465,25 @@ pub enum Automation {
/// This exists because a stale `/usr/bin/cce-power-apply` fails in the one
/// way nothing reports: it takes the pkexec prompt, reads the mode name as
/// an adapter state, and exits 2 — so a pick costs the user an
-/// authentication and changes nothing.
+/// authentication and changes nothing. A helper that knows modes but
+/// predates a lever fails the same way for that lever alone, which is why
+/// the lever list is part of the check.
pub fn helper_speaks_modes(path: &Path) -> bool {
std::process::Command::new(path)
.output()
.is_ok_and(|out| usage_speaks_modes(&String::from_utf8_lossy(&out.stderr)))
}
-/// The usage text of a helper that knows about modes names `apply-mode`;
-/// the pre-modes one lists only `apply`, `set` and `show`.
+/// The usage text of a helper that knows about modes names `apply-mode`
+/// (the pre-modes one lists only `apply`, `set` and `show`), and its
+/// `levers:` line names every lever it accepts.
fn usage_speaks_modes(usage: &str) -> bool {
- usage.contains("apply-mode")
+ let levers: Vec<&str> = usage
+ .lines()
+ .find_map(|l| l.trim().strip_prefix("levers:"))
+ .map(|l| l.split_whitespace().collect())
+ .unwrap_or_default();
+ usage.contains("apply-mode") && Lever::ALL.iter().all(|l| levers.contains(&l.key()))
}
/// Whether the root-side pieces are in place — the helper at its system
@@ -577,6 +591,7 @@ pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
}
write_sysfs(Path::new("/sys/module/snd_hda_intel/parameters/power_save"), value)
}
+ Lever::Animations => write_animations(value),
Lever::GpuLimitW => {
// nvidia-smi validates the watts against the card's own min/max
// and refuses anything outside them, so it is the range check.
@@ -595,6 +610,27 @@ pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
}
}
+/// Record the animations switch where every session reads it
+/// ([`cce_ui::motion::STATE_PATH`]). Not sysfs, but the same shape of lever:
+/// root writes it when the mode changes, and the compositor and every cce-ui
+/// client follow it without a restart. Under /run, not in anyone's config:
+/// this runs as root with no session and no `$HOME`, and a tmpfs file is
+/// rewritten at boot by the same coldplug run that applies the rest of the
+/// mode, so it can never outlive the plan that set it.
+fn write_animations(value: &str) -> Result<(), String> {
+ use std::os::unix::fs::PermissionsExt;
+ let path = Path::new(cce_ui::motion::STATE_PATH);
+ let dir = path.parent().expect("STATE_PATH has a parent");
+ std::fs::create_dir_all(dir).map_err(|e| format!("{}: {}", dir.display(), e))?;
+ // Temp + rename so a reader never sees a torn value; world-readable
+ // because every session's processes read it.
+ let tmp = path.with_extension("tmp");
+ write_sysfs(&tmp, value)?;
+ std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))
+ .map_err(|e| format!("{}: {}", tmp.display(), e))?;
+ std::fs::rename(&tmp, path).map_err(|e| format!("{}: {}", path.display(), e))
+}
+
/// Apply every lever one mode sets. Failures are per lever — a missing
/// NVIDIA driver must not stop the CPU profile from landing — and come back
/// to the caller, which logs them.
@@ -724,6 +760,8 @@ mod tests {
assert!(!Lever::Profile.value_ok("x;reboot"));
assert!(Lever::Turbo.value_ok("off"));
assert!(!Lever::Turbo.value_ok("0"));
+ assert!(Lever::Animations.value_ok("on"));
+ assert!(!Lever::Animations.value_ok("false"));
assert!(Lever::AudioIdleSecs.value_ok("10"));
assert!(!Lever::AudioIdleSecs.value_ok("-1"));
assert!(!Lever::AudioIdleSecs.value_ok("ten"));
@@ -744,9 +782,16 @@ mod tests {
#[test]
fn the_usage_probe_tells_a_mode_helper_from_a_pre_modes_one() {
// What this binary prints today.
- assert!(usage_speaks_modes(
- "usage: cce-power-apply apply [ac|battery]\n cce-power-apply apply-mode <mode>\n"
- ));
+ let levers = Lever::ALL.iter().map(|l| l.key()).collect::<Vec<_>>().join(" ");
+ let current = format!(
+ "usage: cce-power-apply apply [ac|battery]\n cce-power-apply apply-mode <mode>\n \
+ modes: performance balanced power-saver\n levers: {levers}\n"
+ );
+ assert!(usage_speaks_modes(¤t));
+ // One that knows modes but predates a lever takes the prompt and
+ // rejects that lever — as stale, for that pick, as a pre-modes one.
+ let older = current.replace(" animations", "");
+ assert!(!usage_speaks_modes(&older), "{older}");
// What the pre-modes one printed — the copy that silently rejects
// every pick the page makes.
assert!(!usage_speaks_modes(