system settings
git clone https://git.lucas.co/cce-system-interface.git
src/pages/power.rs (66.1K)
1 //! Power: battery facts plus the host's real battery-life levers, all sysfs,
2 //! organized as named power modes and an assignment of a mode to each power
3 //! adapter state.
4 //!
5 //! Every control is discovered from the interfaces this machine actually
6 //! exposes (missing ones render as absent, not as dead widgets):
7 //! - `/sys/firmware/acpi/platform_profile` — firmware power profile
8 //! - `/sys/devices/system/cpu/cpu*/cpufreq/energy_performance_preference`
9 //! - `/sys/class/power_supply/BAT*/charge_control_{start,end}_threshold` —
10 //! capping charge at 80% is the classic battery-longevity lever
11 //! - `/sys/devices/system/cpu/intel_pstate/no_turbo` — turbo boost
12 //!
13 //! Three levers are not hardware interfaces: **Animations**, which the
14 //! helper records at `cce_ui::motion::STATE_PATH` for the compositor and
15 //! every cce-ui client to follow, and the two **idle timeouts** (display
16 //! off, sleep), recorded at `power_plan::IDLE_DISPLAY_OFF_PATH` and
17 //! `IDLE_SLEEP_PATH` for the compositor's idle manager, which lets them
18 //! override its `idle { }` block. They are per mode for the same reason the
19 //! others are — easing every frame costs power, and a display left lit on
20 //! battery costs more — and they are always offered, since every host has
21 //! a display and animations.
22 //!
23 //! The page is three sections. **Battery** is the pack itself: its facts and
24 //! the charge limit, which is a charging policy and so belongs to no mode.
25 //! Each choice is a window — stop at 80%, resume below 75% — kept in the plan
26 //! and re-applied by the helper on every run, because the firmware forgets
27 //! it (until 2026-10-06 a pick here was one sysfs write, gone the first
28 //! time the battery ran flat).
29 //! **Power Mode** edits one mode's levers, chosen by the dropdown at the top
30 //! of the section — one section rather than one per mode, so the levers sit
31 //! in the same place whichever mode is being edited. **Mode Assignment**
32 //! says which mode runs plugged in and which on battery, one dropdown per
33 //! adapter state.
34 //!
35 //! A "Not set" row means "leave that lever alone"; when the edited mode is
36 //! the one running right now, the row also reports the live sysfs value, and
37 //! a pick applies immediately. Everything is remembered in the plan
38 //! (`crate::power_plan`, `/etc/cce/power.kdl`) through `cce-power-apply`
39 //! under pkexec — the one-prompt path every privileged action in this app
40 //! takes — and the same helper re-applies the assigned mode from udev when
41 //! the charger comes or goes. The UI is optimistic and the 5s watcher
42 //! re-reads the truth, so a dismissed auth prompt reverts the dropdown —
43 //! honest, with no extra error channel.
44
45 use crate::app::{AppAction, PageContent};
46 use crate::power_plan::{self, Automation, ChargeLimit, Lever, Mode, PowerPlan, Source};
47 use cce_ui::context::UiContext;
48 use cce_ui::widget::Handle;
49 use cce_ui::layout::{PageFlow, PageLayoutBuilder};
50 use cce_ui::widget::{Adapted, Dropdown};
51 use std::path::{Path, PathBuf};
52
53 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
54 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
55 const GOOD: [f32; 4] = [0.56, 0.83, 0.56, 1.0];
56 const WARN: [f32; 4] = [0.90, 0.75, 0.45, 1.0];
57
58 /// Everything read from sysfs in one pass. Plain data; widget state lives in
59 /// [`PowerState`].
60 #[derive(Debug, Clone, PartialEq, Default)]
61 pub struct PowerFacts {
62 pub battery_present: bool,
63 pub status: String,
64 pub capacity_pct: u32,
65 /// energy_full / energy_full_design — how much the cells have aged.
66 pub health_pct: Option<u32>,
67 /// Instantaneous draw in watts, meaningful while discharging.
68 pub power_w: Option<f32>,
69 pub ac_online: Option<bool>,
70 /// Which plan is in force right now, from the Mains supply.
71 pub source: Source,
72 /// The plan on disk, and the state of the root side that applies it on
73 /// plug/unplug (helper + udev rule).
74 pub plan: PowerPlan,
75 pub automation: Automation,
76 /// platform_profile choices in sysfs spelling, and the active one.
77 pub profiles: Vec<String>,
78 pub profile: String,
79 /// EPP choices in sysfs spelling, and cpu0's current value.
80 pub epps: Vec<String>,
81 pub epp: String,
82 /// energy_now / energy_full in µWh — the Wh readout, and what the
83 /// time-remaining estimate divides by power_now.
84 pub energy_now_uwh: Option<f64>,
85 pub energy_full_uwh: Option<f64>,
86 /// manufacturer + model_name, the pack's own identification.
87 pub vendor: String,
88 pub model: String,
89 /// charge_control_end_threshold, when the battery has one.
90 pub charge_limit: Option<u32>,
91 /// charge_control_start_threshold, when the battery has one too.
92 pub charge_start: Option<u32>,
93 /// intel_pstate no_turbo, inverted to "turbo enabled".
94 pub turbo: Option<bool>,
95 /// scaling_available_governors, and cpu0's active one. Moved here from the
96 /// System page, which drove it through an unversioned pkexec helper script
97 /// whose path had not survived two renames of this app.
98 pub governors: Vec<String>,
99 pub governor: String,
100 /// Intel GPU render-clock ceiling in MHz: (current, hardware max RP0,
101 /// hardware min RPn). This is the iGPU — on a hybrid laptop it is the GPU
102 /// actually drawing power when the discrete one is asleep or driverless.
103 pub igpu_mhz: Option<u32>,
104 pub igpu_max_mhz: Option<u32>,
105 pub igpu_min_mhz: Option<u32>,
106 /// PCIe Active State Power Management: the policy list the kernel offers,
107 /// and the one in brackets.
108 pub aspm_policies: Vec<String>,
109 pub aspm: String,
110 /// snd_hda_intel power_save: seconds of idle before the audio codec
111 /// suspends, 0 meaning never.
112 pub hda_idle_secs: Option<u32>,
113 /// NVIDIA power limit in watts: (current, default, minimum). None whenever
114 /// nvidia-smi cannot reach a driver — including a host where the module is
115 /// simply not loaded — which is what gates the dropdown away.
116 pub gpu_limit_w: Option<u32>,
117 pub gpu_default_w: Option<u32>,
118 pub gpu_min_w: Option<u32>,
119 /// Whether the session animates right now: the helper's state file, or
120 /// on when there is none — which is what the toolkit and compositor do.
121 pub animations: bool,
122 /// The compositor's effective idle timeouts in seconds (0 = never), from
123 /// `ccectl idle status`; None when no compositor answered.
124 pub idle_display_off_secs: Option<u32>,
125 pub idle_sleep_secs: Option<u32>,
126 }
127
128
129 /// The edited mode's column of lever dropdowns. One set, not one per mode:
130 /// the mode dropdown above it decides whose values it is showing.
131 #[derive(Debug, Clone)]
132 pub struct LeverSet {
133 /// One dropdown per [`Lever::ALL`] entry, in that order.
134 pub dds: Vec<Handle<Adapted<Dropdown>>>,
135 /// The plan value behind each row of each dropdown (options are display
136 /// text). Row 0 is always the "Not set" row and holds the empty string;
137 /// an empty Vec means the interface is absent on this host and the
138 /// dropdown is not painted.
139 pub rows: Vec<Vec<String>>,
140 }
141
142 impl LeverSet {
143 /// The set, its dropdowns inserted into `ctx`.
144 pub fn new(ctx: &mut UiContext) -> Self {
145 Self {
146 dds: Lever::ALL
147 .iter()
148 .map(|l| ctx.insert(Dropdown::new(vec!["—".to_string()], 0).with_label(l.label())))
149 .collect(),
150 rows: vec![Vec::new(); Lever::ALL.len()],
151 }
152 }
153 }
154
155 fn lever_index(lever: Lever) -> usize {
156 Lever::ALL.iter().position(|l| *l == lever).unwrap()
157 }
158
159 #[derive(Debug, Clone)]
160 pub struct PowerState {
161 pub loaded: bool,
162 pub facts: PowerFacts,
163 pub dd_limit: Handle<Adapted<Dropdown>>,
164 /// The charge window per charge-limit dropdown row (options are display
165 /// text).
166 pub limit_values: Vec<ChargeLimit>,
167 /// Which mode the lever section is editing. Page state, not plan state:
168 /// it says what is on screen, never what the machine runs.
169 pub editing: Mode,
170 pub dd_mode: Handle<Adapted<Dropdown>>,
171 pub levers: LeverSet,
172 /// One mode picker per [`Source::ALL`] entry, in that order.
173 pub dd_assign: Vec<Handle<Adapted<Dropdown>>>,
174 }
175
176 impl PowerState {
177 /// The page's state, its widgets inserted into `ctx`.
178 pub fn new(ctx: &mut UiContext) -> Self {
179 Self {
180 loaded: false,
181 facts: PowerFacts::default(),
182 dd_limit: ctx.insert(Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit")),
183 limit_values: Vec::new(),
184 editing: Mode::default(),
185 dd_mode: ctx.insert(Dropdown::new(mode_options(), 0).with_label("Mode")),
186 levers: LeverSet::new(ctx),
187 dd_assign: Source::ALL
188 .iter()
189 .map(|s| ctx.insert(Dropdown::new(mode_options(), 0).with_label(s.label())))
190 .collect(),
191 }
192 }
193 }
194
195 fn mode_options() -> Vec<String> {
196 Mode::ALL.iter().map(|m| m.label().to_string()).collect()
197 }
198
199 /// Which adapter states the assignment section offers. A host with no
200 /// battery has one, so the battery row would be an assignment for a state it
201 /// never enters.
202 fn sources_shown(f: &PowerFacts) -> Vec<Source> {
203 if f.battery_present { vec![Source::Ac, Source::Battery] } else { vec![Source::Ac] }
204 }
205
206 /// Whether the assignment section is worth painting at all — on a host with
207 /// a single adapter state there is nothing to choose between.
208 fn assignment_shown(f: &PowerFacts) -> bool {
209 sources_shown(f).len() > 1
210 }
211
212 #[derive(Debug, Clone)]
213 pub enum PowerMessage {
214 Refreshed(PowerFacts),
215 /// Charge-limit pick, by option index.
216 SetLimit(usize),
217 /// Which mode the lever section edits, by option index. Page-local: it
218 /// writes nothing and applies nothing.
219 EditMode(usize),
220 /// A lever pick on the mode being edited, by option index.
221 Set { lever: Lever, idx: usize },
222 /// Which mode an adapter state runs, by option index.
223 Assign { source: Source, idx: usize },
224 }
225
226 /// The helper that records and applies the plan: the system copy when
227 /// `ccebuild install-system` has put a current one there, else the one
228 /// installed beside this binary (`~/.local/bin`) — which pkexec will still
229 /// run as root after the prompt, so the plan works before the root side is
230 /// installed; only the automatic switching waits on it.
231 ///
232 /// The system copy has to speak the current CLI to be preferred. A stale
233 /// one there is worse than none: it takes the pkexec prompt, reads the mode
234 /// name as an adapter state and exits 2, so every pick costs an
235 /// authentication and changes nothing. Falling through to the local copy
236 /// keeps the page working; the Battery section is where the user is told
237 /// the root side is behind.
238 fn helper_path() -> Option<PathBuf> {
239 let sys = Path::new(power_plan::HELPER_SYSTEM_PATH);
240 if sys.exists() && power_plan::helper_speaks_modes(sys) {
241 return Some(sys.to_path_buf());
242 }
243 let beside = std::env::current_exe().ok()?.parent()?.join("cce-power-apply");
244 if beside.exists() {
245 return Some(beside);
246 }
247 sys.exists().then(|| sys.to_path_buf())
248 }
249
250 /// Display form of a sysfs token: `balance_power` → "Balance Power".
251 fn pretty(token: &str) -> String {
252 token
253 .split(['_', '-'])
254 .filter(|w| !w.is_empty())
255 .map(|w| {
256 let mut cs = w.chars();
257 match cs.next() {
258 Some(f) => f.to_uppercase().collect::<String>() + cs.as_str(),
259 None => String::new(),
260 }
261 })
262 .collect::<Vec<_>>()
263 .join(" ")
264 }
265
266 /// Seconds of runtime left while discharging, or to full while charging, from
267 /// energy over draw. sysfs has no time field — UPower computes exactly this,
268 /// and reading it here is what lets the Power page stay all-sysfs rather than
269 /// taking a D-Bus dependency for one line.
270 fn battery_seconds(f: &PowerFacts) -> Option<i64> {
271 let now = f.energy_now_uwh?;
272 let full = f.energy_full_uwh?;
273 let draw = f.power_w? as f64 * 1e6;
274 if draw <= 0.0 {
275 return None;
276 }
277 let remaining_uwh = match f.status.as_str() {
278 "Discharging" => now,
279 "Charging" => (full - now).max(0.0),
280 _ => return None,
281 };
282 Some(((remaining_uwh / draw) * 3600.0).round() as i64)
283 }
284
285 fn humanize_secs(secs: i64) -> String {
286 let (h, m) = (secs / 3600, (secs % 3600) / 60);
287 if h > 0 { format!("{}h {}m", h, m) } else { format!("{}m", m) }
288 }
289
290 fn read_trim(path: &str) -> Option<String> {
291 std::fs::read_to_string(path).ok().map(|s| s.trim().to_string())
292 }
293
294 /// The first /sys/class/drm/card* exposing `gt_max_freq_mhz` (i915/xe).
295 fn drm_card_with_freq() -> Option<std::path::PathBuf> {
296 let rd = std::fs::read_dir("/sys/class/drm").ok()?;
297 let mut cards: Vec<_> = rd
298 .flatten()
299 .map(|e| e.path())
300 .filter(|p| {
301 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("card"))
302 && p.join("gt_max_freq_mhz").exists()
303 })
304 .collect();
305 cards.sort();
306 cards.into_iter().next()
307 }
308
309 /// The first battery under /sys/class/power_supply, by convention BAT*.
310 pub(crate) fn battery_dir() -> Option<std::path::PathBuf> {
311 let rd = std::fs::read_dir("/sys/class/power_supply").ok()?;
312 let mut bats: Vec<_> = rd
313 .flatten()
314 .map(|e| e.path())
315 .filter(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("BAT")))
316 .collect();
317 bats.sort();
318 bats.into_iter().next()
319 }
320
321 pub async fn fetch_power_state() -> PowerFacts {
322 let mut f = PowerFacts::default();
323
324 if let Some(bat) = battery_dir() {
325 let b = |name: &str| read_trim(&format!("{}/{}", bat.display(), name));
326 f.battery_present = true;
327 f.status = b("status").unwrap_or_else(|| "Unknown".to_string());
328 f.capacity_pct = b("capacity").and_then(|s| s.parse().ok()).unwrap_or(0);
329 // Health from energy_* or charge_* — batteries report one family.
330 let full: Option<f64> = b("energy_full").or_else(|| b("charge_full")).and_then(|s| s.parse().ok());
331 let design: Option<f64> =
332 b("energy_full_design").or_else(|| b("charge_full_design")).and_then(|s| s.parse().ok());
333 if let (Some(full), Some(design)) = (full, design) {
334 if design > 0.0 {
335 f.health_pct = Some(((full / design) * 100.0).round() as u32);
336 }
337 }
338 f.power_w = b("power_now").and_then(|s| s.parse::<f64>().ok()).map(|uw| (uw / 1e6) as f32);
339 f.energy_now_uwh = b("energy_now").and_then(|s| s.parse().ok());
340 f.energy_full_uwh = full;
341 f.vendor = b("manufacturer").unwrap_or_default();
342 f.model = b("model_name").unwrap_or_default();
343 f.charge_limit = b("charge_control_end_threshold").and_then(|s| s.parse().ok());
344 f.charge_start = b("charge_control_start_threshold").and_then(|s| s.parse().ok());
345 }
346 f.ac_online = read_trim("/sys/class/power_supply/AC/online").map(|s| s == "1");
347 f.source = power_plan::current_source();
348 // An unreadable plan shows as empty here; the applier is the side that
349 // refuses to act on it, and logs why.
350 f.plan = PowerPlan::load().unwrap_or_else(|e| {
351 log::warn!("[power] {}", e);
352 PowerPlan::default()
353 });
354 f.automation = power_plan::automation_status();
355
356 if let Some(choices) = read_trim("/sys/firmware/acpi/platform_profile_choices") {
357 f.profiles = choices.split_whitespace().map(String::from).collect();
358 f.profile = read_trim("/sys/firmware/acpi/platform_profile").unwrap_or_default();
359 }
360
361 if let Some(prefs) =
362 read_trim("/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_available_preferences")
363 {
364 f.epps = prefs.split_whitespace().map(String::from).collect();
365 f.epp = read_trim("/sys/devices/system/cpu/cpu0/cpufreq/energy_performance_preference")
366 .unwrap_or_default();
367 }
368
369 f.turbo = read_trim("/sys/devices/system/cpu/intel_pstate/no_turbo").map(|s| s == "0");
370
371 // The first DRM card exposing the i915/xe clock knobs. Globbed rather than
372 // fixed at card0: the render node's number depends on probe order, and on a
373 // hybrid machine the Intel one is not necessarily first.
374 if let Some(card) = drm_card_with_freq() {
375 let g = |name: &str| read_trim(&format!("{}/{}", card.display(), name))
376 .and_then(|s| s.parse::<u32>().ok());
377 f.igpu_mhz = g("gt_max_freq_mhz");
378 f.igpu_max_mhz = g("gt_RP0_freq_mhz");
379 f.igpu_min_mhz = g("gt_RPn_freq_mhz");
380 }
381
382 if let Some(pol) = read_trim("/sys/module/pcie_aspm/parameters/policy") {
383 // "[default] performance powersave powersupersave" — brackets mark the
384 // active one, and the list is whatever this kernel was built with.
385 for tok in pol.split_whitespace() {
386 let bare = tok.trim_start_matches('[').trim_end_matches(']');
387 if tok.starts_with('[') {
388 f.aspm = bare.to_string();
389 }
390 f.aspm_policies.push(bare.to_string());
391 }
392 }
393
394 f.animations = cce_ui::motion::read_state().unwrap_or(true);
395
396 f.hda_idle_secs =
397 read_trim("/sys/module/snd_hda_intel/parameters/power_save").and_then(|s| s.parse().ok());
398
399 // The compositor reports its effective timeouts (plan override or config)
400 // in `key=<secs>s` fields; no compositor, no rows worth a live value.
401 if let Ok(out) = tokio::process::Command::new("ccectl").args(["idle", "status"]).output().await {
402 if out.status.success() {
403 let text = String::from_utf8_lossy(&out.stdout);
404 f.idle_display_off_secs = idle_status_field(&text, "display_off");
405 f.idle_sleep_secs = idle_status_field(&text, "sleep");
406 }
407 }
408
409 if let Some(govs) = read_trim("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors") {
410 f.governors = govs.split_whitespace().map(String::from).collect();
411 f.governor = read_trim("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").unwrap_or_default();
412 }
413
414 (f.gpu_limit_w, f.gpu_default_w, f.gpu_min_w) = gpu_limits(gpu_limits_key(&f)).await;
415
416 f
417 }
418
419 /// The NVIDIA power limits `(current, default, minimum)` in watts, the last
420 /// time nvidia-smi was asked, and what it was asked under.
421 static GPU_LIMITS: std::sync::Mutex<Option<(u64, GpuLimits)>> = std::sync::Mutex::new(None);
422 type GpuLimits = (Option<u32>, Option<u32>, Option<u32>);
423
424 /// What decides the current limit: the power source and the plan — the
425 /// root helper re-applies a mode's limit when either changes.
426 fn gpu_limits_key(f: &PowerFacts) -> u64 {
427 use std::hash::{Hash, Hasher};
428 let mut h = std::collections::hash_map::DefaultHasher::new();
429 format!("{:?}|{:?}", f.source, f.plan).hash(&mut h);
430 h.finish()
431 }
432
433 /// The power limits, from one nvidia-smi call for all three; any failure
434 /// (no driver, module not loaded, no card) leaves them None and hides the
435 /// dropdown. The query wakes a runtime-suspended card, so while the card
436 /// sleeps the last answer stands unless the source or the plan moved since —
437 /// until 2026-10-05 the page asked every five seconds, which held the dGPU
438 /// awake the whole time it was open.
439 async fn gpu_limits(key: u64) -> GpuLimits {
440 if !crate::power_meter::dgpu_awake() {
441 if let Some((k, limits)) = *GPU_LIMITS.lock().unwrap() {
442 if k == key {
443 return limits;
444 }
445 }
446 }
447 let mut limits: GpuLimits = (None, None, None);
448 if let Ok(out) = tokio::process::Command::new("nvidia-smi")
449 .args(["--query-gpu=power.limit,power.default_limit,power.min_limit", "--format=csv,noheader,nounits"])
450 .output()
451 .await
452 {
453 if out.status.success() {
454 let text = String::from_utf8_lossy(&out.stdout);
455 if let Some(row) = text.lines().next() {
456 let w: Vec<Option<u32>> = row
457 .split(',')
458 .map(|c| c.trim().parse::<f32>().ok().map(|v| v.round() as u32))
459 .collect();
460 limits = (w.first().copied().flatten(), w.get(1).copied().flatten(), w.get(2).copied().flatten());
461 }
462 }
463 }
464 *GPU_LIMITS.lock().unwrap() = Some((key, limits));
465 limits
466 }
467
468 /// One `<key>=<n>s` field of `ccectl idle status`.
469 fn idle_status_field(status: &str, key: &str) -> Option<u32> {
470 status
471 .split_whitespace()
472 .find_map(|tok| tok.strip_prefix(key)?.strip_prefix('='))
473 .and_then(|v| v.strip_suffix('s'))
474 .and_then(|v| v.parse().ok())
475 }
476
477 /// The idle timeouts a mode may pick, in seconds. Fixed rungs rather than a
478 /// free field: a dropdown is what every other lever is, and these cover the
479 /// range anyone sets (0 = never).
480 const IDLE_DISPLAY_OFF_CHOICES: [u32; 7] = [0, 60, 120, 300, 600, 900, 1800];
481 const IDLE_SLEEP_CHOICES: [u32; 7] = [0, 300, 600, 900, 1800, 3600, 7200];
482
483 // ── Lever rows ──────────────────────────────────────────────────────────
484
485 /// The choices this host offers for a lever, as (plan value, display text)
486 /// in menu order. Empty means the interface is absent and the dropdown is
487 /// not painted. Numeric levers get the hardware's own figures — ceiling,
488 /// midpoint, floor for the iGPU; default and minimum for the dGPU — never
489 /// invented numbers.
490 fn choices(lever: Lever, f: &PowerFacts) -> Vec<(String, String)> {
491 let tokens = |list: &[String]| list.iter().map(|t| (t.clone(), pretty(t))).collect::<Vec<_>>();
492 match lever {
493 Lever::Profile => tokens(&f.profiles),
494 Lever::Epp => tokens(&f.epps),
495 Lever::Governor => tokens(&f.governors),
496 Lever::Aspm => tokens(&f.aspm_policies),
497 Lever::Animations => {
498 vec![("on".to_string(), "Enabled".to_string()), ("off".to_string(), "Disabled".to_string())]
499 }
500 Lever::Turbo => {
501 if f.turbo.is_some() {
502 vec![("on".to_string(), "Enabled".to_string()), ("off".to_string(), "Disabled".to_string())]
503 } else {
504 Vec::new()
505 }
506 }
507 Lever::IgpuMaxMhz => {
508 let mut out = Vec::new();
509 if let (Some(hi), Some(lo)) = (f.igpu_max_mhz, f.igpu_min_mhz) {
510 out.push((hi.to_string(), format!("{} MHz (full)", hi)));
511 let mid = ((hi + lo) / 2 / 100) * 100;
512 if mid > lo && mid < hi {
513 out.push((mid.to_string(), format!("{} MHz", mid)));
514 }
515 out.push((lo.to_string(), format!("{} MHz (minimum)", lo)));
516 }
517 out
518 }
519 Lever::AudioIdleSecs => {
520 if f.hda_idle_secs.is_some() {
521 [0u32, 1, 10].iter().map(|v| (v.to_string(), display_of(Lever::AudioIdleSecs, &v.to_string()))).collect()
522 } else {
523 Vec::new()
524 }
525 }
526 Lever::IdleDisplayOffSecs => IDLE_DISPLAY_OFF_CHOICES
527 .iter()
528 .map(|v| (v.to_string(), display_of(Lever::IdleDisplayOffSecs, &v.to_string())))
529 .collect(),
530 Lever::IdleSleepSecs => IDLE_SLEEP_CHOICES
531 .iter()
532 .map(|v| (v.to_string(), display_of(Lever::IdleSleepSecs, &v.to_string())))
533 .collect(),
534 Lever::GpuLimitW => {
535 let mut out: Vec<(String, String)> = Vec::new();
536 if let Some(d) = f.gpu_default_w {
537 out.push((d.to_string(), format!("{} W (default)", d)));
538 }
539 if let Some(m) = f.gpu_min_w {
540 if Some(m) != f.gpu_default_w {
541 out.push((m.to_string(), format!("{} W (minimum)", m)));
542 }
543 }
544 out
545 }
546 }
547 }
548
549 /// What sysfs says right now, in plan-value spelling.
550 fn live(lever: Lever, f: &PowerFacts) -> Option<String> {
551 let nonempty = |s: &String| if s.is_empty() { None } else { Some(s.clone()) };
552 match lever {
553 Lever::Profile => nonempty(&f.profile),
554 Lever::Epp => nonempty(&f.epp),
555 Lever::Governor => nonempty(&f.governor),
556 Lever::Aspm => nonempty(&f.aspm),
557 Lever::Turbo => f.turbo.map(|t| if t { "on" } else { "off" }.to_string()),
558 Lever::IgpuMaxMhz => f.igpu_mhz.map(|v| v.to_string()),
559 Lever::AudioIdleSecs => f.hda_idle_secs.map(|v| v.to_string()),
560 Lever::GpuLimitW => f.gpu_limit_w.map(|v| v.to_string()),
561 Lever::Animations => Some(if f.animations { "on" } else { "off" }.to_string()),
562 Lever::IdleDisplayOffSecs => f.idle_display_off_secs.map(|v| v.to_string()),
563 Lever::IdleSleepSecs => f.idle_sleep_secs.map(|v| v.to_string()),
564 }
565 }
566
567 /// Display text for a value that is not one of the host's listed choices
568 /// (the charge-limit rule: an off-list value gets its own row rather than
569 /// silently matching the wrong one).
570 fn display_of(lever: Lever, value: &str) -> String {
571 match lever {
572 Lever::Turbo | Lever::Animations => if value == "on" { "Enabled" } else { "Disabled" }.to_string(),
573 Lever::IgpuMaxMhz => format!("{} MHz", value),
574 Lever::AudioIdleSecs => {
575 if value == "0" { "Never suspend".to_string() } else { format!("After {} s idle", value) }
576 }
577 Lever::GpuLimitW => format!("{} W", value),
578 Lever::IdleDisplayOffSecs | Lever::IdleSleepSecs => idle_secs_text(value),
579 _ => pretty(value),
580 }
581 }
582
583 /// "Never", or the duration in the unit it reads best in.
584 fn idle_secs_text(value: &str) -> String {
585 match value.parse::<u32>() {
586 Ok(0) => "Never".to_string(),
587 Ok(s) if s % 3600 == 0 => format!("After {} h", s / 3600),
588 Ok(s) if s % 60 == 0 => format!("After {} min", s / 60),
589 Ok(s) => format!("After {} s", s),
590 Err(_) => value.to_string(),
591 }
592 }
593
594 /// Reflect a value the user just applied into the live facts, so the next
595 /// watcher read (which will say the same thing) does not rebuild the menus.
596 fn set_live(lever: Lever, value: &str, f: &mut PowerFacts) {
597 match lever {
598 Lever::Profile => f.profile = value.to_string(),
599 Lever::Epp => f.epp = value.to_string(),
600 Lever::Governor => f.governor = value.to_string(),
601 Lever::Aspm => f.aspm = value.to_string(),
602 Lever::Turbo => f.turbo = Some(value == "on"),
603 Lever::IgpuMaxMhz => f.igpu_mhz = value.parse().ok(),
604 Lever::AudioIdleSecs => f.hda_idle_secs = value.parse().ok(),
605 Lever::GpuLimitW => {
606 f.gpu_limit_w = value.parse().ok();
607 // The page set it: what a sleeping card's cached answer says now.
608 if let Some((_, limits)) = GPU_LIMITS.lock().unwrap().as_mut() {
609 limits.0 = f.gpu_limit_w;
610 }
611 }
612 Lever::Animations => f.animations = value == "on",
613 Lever::IdleDisplayOffSecs => f.idle_display_off_secs = value.parse().ok(),
614 Lever::IdleSleepSecs => f.idle_sleep_secs = value.parse().ok(),
615 }
616 }
617
618 /// Rebuild the lever dropdowns for the mode being edited. Every lever leads
619 /// with a "Not set" row meaning "leave it alone"; when the edited mode is
620 /// the one running right now that row also names the live sysfs value, which
621 /// is where the page reports what the machine is actually doing. A planned
622 /// value missing from the host's list gets appended as its own row. Skipped
623 /// per dropdown while it is open (the default_apps rule: never yank an open
624 /// menu out from under the pointer — the next refresh normalizes it).
625 fn fill_levers(levers: &mut LeverSet, f: &PowerFacts, mode: Mode, ctx: &mut UiContext) {
626 let running = mode == f.plan.assigned(f.source);
627 for (i, lever) in Lever::ALL.iter().enumerate() {
628 if ctx[levers.dds[i]].open {
629 continue;
630 }
631 let mut rows = choices(*lever, f);
632 if rows.is_empty() {
633 levers.rows[i].clear();
634 ctx[levers.dds[i]].options = vec!["—".to_string()];
635 ctx[levers.dds[i]].selected = 0;
636 continue;
637 }
638 let planned: Option<String> = f.plan.get(mode, *lever).map(str::to_string);
639 if let Some(s) = &planned {
640 if !rows.iter().any(|(v, _)| v == s) {
641 rows.push((s.clone(), display_of(*lever, s)));
642 }
643 }
644 let mut values = Vec::with_capacity(rows.len() + 1);
645 let mut options = Vec::with_capacity(rows.len() + 1);
646 values.push(String::new());
647 options.push(match live(*lever, f) {
648 Some(cur) if running => format!("Not set — now {}", display_of(*lever, &cur)),
649 _ => "Not set".to_string(),
650 });
651 for (v, d) in rows {
652 values.push(v);
653 options.push(d);
654 }
655 ctx[levers.dds[i]].selected = planned.and_then(|s| values.iter().position(|v| *v == s)).unwrap_or(0);
656 ctx[levers.dds[i]].options = options;
657 levers.rows[i] = values;
658 }
659 }
660
661 fn mode_index(mode: Mode) -> usize {
662 Mode::ALL.iter().position(|m| *m == mode).unwrap()
663 }
664
665 fn source_index(source: Source) -> usize {
666 Source::ALL.iter().position(|s| *s == source).unwrap()
667 }
668
669 /// The charge-limit rows: each preset is a window that stops at its end and
670 /// resumes five points below it, so a pack held there is not topped up by a
671 /// percent at a time. A battery with no start threshold gets the ends
672 /// alone. The row shown is the plan's window, or — before anything has been
673 /// planned — what the battery reports; a window that is neither preset gets
674 /// its own row rather than silently matching the wrong one.
675 fn charge_rows(f: &PowerFacts) -> (Vec<ChargeLimit>, Vec<String>, usize) {
676 let has_start = f.charge_start.is_some();
677 let window = |start: u32, end: u32| ChargeLimit { start: has_start.then_some(start), end: Some(end) };
678 let mut values = vec![window(0, 100), window(75, 80), window(55, 60)];
679 let mut options = vec![
680 "100% — full capacity".to_string(),
681 "80% — longevity".to_string(),
682 "60% — max longevity".to_string(),
683 ];
684 let planned = f.plan.charge_limit();
685 let current = if planned.is_unset() { ChargeLimit { start: f.charge_start, end: f.charge_limit } } else { planned };
686 let selected = match values.iter().position(|v| *v == current) {
687 Some(i) => i,
688 None => match current.end {
689 Some(end) => {
690 values.push(current);
691 options.push(match current.start {
692 Some(start) if start > 0 => format!("{}% — current, from {}%", end, start),
693 _ => format!("{}% — current", end),
694 });
695 values.len() - 1
696 }
697 None => 0,
698 },
699 };
700 (values, options, selected)
701 }
702
703 /// Rebuild every dropdown's options/selection from fresh facts.
704 fn rebuild_options(state: &mut PowerState, ctx: &mut UiContext) {
705 let f = &state.facts;
706 if !ctx[state.dd_limit].open {
707 let (values, options, selected) = charge_rows(f);
708 ctx[state.dd_limit].options = options;
709 ctx[state.dd_limit].selected = selected;
710 state.limit_values = values;
711 }
712 if !ctx[state.dd_mode].open {
713 ctx[state.dd_mode].options = mode_options();
714 ctx[state.dd_mode].selected = mode_index(state.editing);
715 }
716 fill_levers(&mut state.levers, &state.facts, state.editing, ctx);
717 for source in Source::ALL {
718 let i = source_index(source);
719 if ctx[state.dd_assign[i]].open {
720 continue;
721 }
722 ctx[state.dd_assign[i]].options = mode_options();
723 ctx[state.dd_assign[i]].selected = mode_index(state.facts.plan.assigned(source));
724 }
725 }
726
727 /// How the page says when a mode runs, in a sentence.
728 fn when_text(source: Source) -> &'static str {
729 match source {
730 Source::Ac => "plugged in",
731 Source::Battery => "unplugged",
732 }
733 }
734 pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
735 let mut final_pc = PageContent::new();
736 let sec_w = 320.0f32;
737 let focused = |i: usize| sec_focused.get(i).copied().unwrap_or(false);
738
739 if !state.loaded {
740 let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
741 builder.add_section_spanned(&mut final_pc, "", 1, focused(0), |sec| {
742 let mut form = sec.form();
743 form.column().text("Reading power interfaces...", 12.0, TEXT_DIM);
744 sec.place(form, ctx);
745 });
746 return final_pc;
747 }
748
749 let PowerState { facts, dd_limit, editing, dd_mode, levers, dd_assign, .. } = state;
750 let (dd_limit, dd_mode) = (*dd_limit, *dd_mode);
751 let editing = *editing;
752 let sources = sources_shown(facts);
753 let show_assign = assignment_shown(facts);
754 let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w)
755 .with_section_count(if show_assign { 3 } else { 2 });
756 let menu_h = cce_ui::layout::dropdown_height();
757
758 // ── Battery: facts, the charge limit, and whether switching is wired up ──
759 builder.add_section(&mut final_pc, "Battery", focused(0), |sec| {
760 let f = &*facts;
761 let mut form = sec.form();
762 let mut col = form.column();
763 col.block(|b| {
764 if !f.battery_present {
765 b.text("No battery detected", 12.0, TEXT_DIM);
766 return;
767 }
768 let status_color = match f.status.as_str() {
769 "Charging" | "Full" => GOOD,
770 "Discharging" => WARN,
771 _ => TEXT_FG,
772 };
773 let mut line = format!("{}% · {}", f.capacity_pct, f.status);
774 if let (Some(w), "Discharging") = (f.power_w, f.status.as_str()) {
775 line.push_str(&format!(" · {:.1} W draw", w));
776 }
777 if let Some(true) = f.ac_online {
778 line.push_str(" · on AC");
779 }
780 b.text(line, 12.0, status_color);
781
782 // Folded in from the System page's battery section, which showed
783 // the same pack in a second place until 2026-08-23.
784 let mut detail = String::new();
785 if let (Some(now), Some(full)) = (f.energy_now_uwh, f.energy_full_uwh) {
786 detail.push_str(&format!("{:.1} / {:.1} Wh", now / 1e6, full / 1e6));
787 }
788 if let Some(secs) = battery_seconds(f) {
789 if !detail.is_empty() {
790 detail.push_str(" · ");
791 }
792 let what = if f.status == "Charging" { "to full" } else { "remaining" };
793 detail.push_str(&format!("{} {}", humanize_secs(secs), what));
794 }
795 if !detail.is_empty() {
796 b.text(detail, 12.0, TEXT_DIM);
797 }
798 if let Some(h) = f.health_pct {
799 b.text(format!("Health: {}% of design capacity", h), 12.0, if h >= 80 { TEXT_DIM } else { WARN });
800 }
801 let pack = format!("{} {}", f.vendor, f.model);
802 if !pack.trim().is_empty() {
803 b.text(pack.trim().to_string(), 11.0, TEXT_DIM);
804 }
805 });
806
807 if f.charge_limit.is_some() {
808 col.widget_h(ctx, dd_limit, menu_h);
809 }
810
811 if f.battery_present {
812 col.block(|b| match f.automation {
813 Automation::Ready => {
814 b.text("Switches automatically on plug and unplug.", 11.0, TEXT_DIM);
815 }
816 Automation::Missing => {
817 b.text("Automatic switching is not installed:", 11.0, WARN);
818 b.text("System › System Files installs it.", 11.0, WARN);
819 }
820 Automation::Stale => {
821 b.text("Automatic switching is out of date and", 11.0, WARN);
822 b.text("applies nothing on plug or unplug.", 11.0, WARN);
823 b.text("Run: ccebuild install-system", 11.0, WARN);
824 }
825 });
826 }
827 sec.place(form, ctx);
828 });
829
830 // ── The edited mode's levers, behind the picker that chooses it ──
831 {
832 let f = &*facts;
833 let running = f.plan.assigned(f.source) == editing;
834 let applies_when: Vec<&str> = sources
835 .iter()
836 .filter(|s| f.plan.assigned(**s) == editing)
837 .map(|s| when_text(*s))
838 .collect();
839 builder.add_section(&mut final_pc, "Power Mode", focused(1), |sec| {
840 let mut form = sec.form();
841 let mut col = form.column();
842 col.widget_h(ctx, dd_mode, menu_h);
843 col.block(|b| {
844 if running {
845 b.text("Running now — picks apply immediately.", 11.0, GOOD);
846 } else if applies_when.is_empty() {
847 b.text("Assigned to no adapter state.", 11.0, TEXT_DIM);
848 } else {
849 b.text(format!("Applied when {}.", applies_when.join(" and ")), 11.0, TEXT_DIM);
850 }
851 b.text("Not set leaves a lever alone.", 11.0, TEXT_DIM);
852 });
853 for (row, &dd) in levers.rows.iter().zip(levers.dds.iter()) {
854 if !row.is_empty() {
855 col.widget_h(ctx, dd, menu_h);
856 }
857 }
858 sec.place(form, ctx);
859 });
860 }
861
862 // ── Which mode each power adapter state runs ──
863 if show_assign {
864 let f = &*facts;
865 builder.add_section(&mut final_pc, "Mode Assignment", focused(2), |sec| {
866 let mut form = sec.form();
867 let mut col = form.column();
868 col.block(|b| {
869 b.text("Which mode runs in each adapter state.", 11.0, TEXT_DIM);
870 b.text(format!("{} right now.", f.source.label()), 11.0, GOOD);
871 });
872 let wanted: Vec<usize> = sources.iter().map(|s| source_index(*s)).collect();
873 for (i, &dd) in dd_assign.iter().enumerate() {
874 if wanted.contains(&i) {
875 col.widget_h(ctx, dd, menu_h);
876 }
877 }
878 sec.place(form, ctx);
879 });
880 }
881
882 final_pc
883 }
884
885 pub fn update(state: &mut PowerState, msg: PowerMessage, ctx: &mut UiContext) {
886 match msg {
887 PowerMessage::Refreshed(facts) => {
888 // The page opens on the mode the machine is actually running, so
889 // the first thing on screen describes the present rather than a
890 // mode nothing is using. Only the first read moves it — after
891 // that the pick is the user's.
892 if !state.loaded {
893 state.editing = facts.plan.assigned(facts.source);
894 }
895 state.loaded = true;
896 if state.facts != facts {
897 state.facts = facts;
898 rebuild_options(state, ctx);
899 }
900 }
901 PowerMessage::SetLimit(idx) => {
902 let Some(limit) = state.limit_values.get(idx).copied() else {
903 return;
904 };
905 // The rows are built from presets and sysfs reads, but the check
906 // makes the argv safe by construction, as the lever guard does.
907 if limit.check().is_err() || limit.end.is_none() {
908 return;
909 }
910 let Some(helper) = helper_path() else {
911 log::error!("[power] cce-power-apply not found at {} or beside this binary", power_plan::HELPER_SYSTEM_PATH);
912 return;
913 };
914 let arg = |pct: Option<u32>| pct.map_or_else(|| "unset".to_string(), |p| p.to_string());
915 // Through the helper rather than a one-off sysfs write: it records
916 // the window in the plan, which every later run re-applies.
917 let _ = std::process::Command::new("pkexec")
918 .arg(&helper)
919 .arg("charge-limit")
920 .arg(arg(limit.start))
921 .arg(arg(limit.end))
922 .spawn();
923 // Optimistic mirror of what the helper will make true.
924 let _ = state.facts.plan.set_charge_limit(limit);
925 state.facts.charge_limit = limit.end;
926 if limit.start.is_some() {
927 state.facts.charge_start = limit.start;
928 }
929 rebuild_options(state, ctx);
930 }
931 PowerMessage::EditMode(idx) => {
932 // Page-local: switching which mode is on screen writes nothing
933 // and applies nothing, so it needs no privileged call.
934 if let Some(mode) = Mode::ALL.get(idx).copied() {
935 state.editing = mode;
936 rebuild_options(state, ctx);
937 }
938 }
939 PowerMessage::Set { lever, idx } => {
940 let i = lever_index(lever);
941 let Some(value) = state.levers.rows.get(i).and_then(|r| r.get(idx)).cloned() else {
942 return;
943 };
944 let value: Option<&str> = if value.is_empty() { None } else { Some(value.as_str()) };
945 // The row values come from sysfs reads and the plan, but the guard
946 // makes the argv safe by construction rather than by data flow.
947 if value.is_some_and(|v| !lever.value_ok(v)) {
948 return;
949 }
950 let mode = state.editing;
951 let Some(helper) = helper_path() else {
952 log::error!("[power] cce-power-apply not found at {} or beside this binary", power_plan::HELPER_SYSTEM_PATH);
953 return;
954 };
955 // Under pkexec, the app's standard privileged path, and detached:
956 // the polkit prompt runs in its own process and the UI never
957 // blocks. The helper records the pick and, when this mode is the
958 // running one, applies it; the watcher's next read reports what
959 // actually happened.
960 let _ = std::process::Command::new("pkexec")
961 .arg(&helper)
962 .arg("set")
963 .arg(mode.key())
964 .arg(lever.key())
965 .arg(value.unwrap_or("unset"))
966 .spawn();
967 // Optimistic mirror of what the helper will make true.
968 let _ = state.facts.plan.put(mode, lever, value);
969 if mode == state.facts.plan.assigned(state.facts.source) {
970 if let Some(v) = value {
971 set_live(lever, v, &mut state.facts);
972 }
973 }
974 rebuild_options(state, ctx);
975 }
976 PowerMessage::Assign { source, idx } => {
977 let Some(mode) = Mode::ALL.get(idx).copied() else {
978 return;
979 };
980 let Some(helper) = helper_path() else {
981 log::error!("[power] cce-power-apply not found at {} or beside this binary", power_plan::HELPER_SYSTEM_PATH);
982 return;
983 };
984 let _ = std::process::Command::new("pkexec")
985 .arg(&helper)
986 .arg("assign")
987 .arg(source.key())
988 .arg(mode.key())
989 .spawn();
990 state.facts.plan.assign(source, mode);
991 // Reassigning the live state hands the machine to a different
992 // mode; mirror its levers so the page agrees with what the helper
993 // is applying until the watcher's next read.
994 if source == state.facts.source {
995 let values: Vec<(Lever, String)> =
996 state.facts.plan.levers(mode).map(|(l, v)| (l, v.to_string())).collect();
997 for (lever, value) in values {
998 set_live(lever, &value, &mut state.facts);
999 }
1000 }
1001 rebuild_options(state, ctx);
1002 }
1003 }
1004 }
1005
1006 impl crate::pages::AppPage for PowerState {
1007 // Sections: [Battery, Power Mode, Mode Assignment] — ids mirror the
1008 // view's load gate AND its per-interface presence gates (the d13a901
1009 // lesson: never report a widget the view didn't paint).
1010 fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
1011 if !self.loaded {
1012 return vec![Vec::new()];
1013 }
1014 let mut out = Vec::new();
1015 let mut first = Vec::new();
1016 if self.facts.charge_limit.is_some() {
1017 first.push(self.dd_limit.id());
1018 }
1019 out.push(first);
1020 let mut mode_sec = vec![self.dd_mode.id()];
1021 mode_sec.extend(
1022 (0..Lever::ALL.len())
1023 .filter(|i| !self.levers.rows[*i].is_empty())
1024 .map(|i| self.levers.dds[i].id()),
1025 );
1026 out.push(mode_sec);
1027 if assignment_shown(&self.facts) {
1028 let ids = sources_shown(&self.facts)
1029 .into_iter()
1030 .map(|s| self.dd_assign[source_index(s)].id())
1031 .collect();
1032 out.push(ids);
1033 }
1034 out
1035 }
1036
1037 fn view(
1038 &mut self,
1039 cx: f32,
1040 cy: f32,
1041 cw: f32,
1042 ch: f32,
1043 root_focused: bool,
1044 sec_focused: &[bool],
1045 layout: &mut PageFlow,
1046 ctx: &mut cce_ui::context::UiContext,
1047 ) -> crate::app::PageContent {
1048 view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
1049 }
1050
1051 fn propagate_widget_changes(&mut self, actions: &mut Vec<AppAction>, ctx: &mut UiContext) {
1052 if ctx[self.dd_limit].take_change() {
1053 actions.push(AppAction::Power(PowerMessage::SetLimit(ctx[self.dd_limit].selected)));
1054 }
1055 if ctx[self.dd_mode].take_change() {
1056 actions.push(AppAction::Power(PowerMessage::EditMode(ctx[self.dd_mode].selected)));
1057 }
1058 for (i, lever) in Lever::ALL.iter().enumerate() {
1059 if ctx[self.levers.dds[i]].take_change() {
1060 actions.push(AppAction::Power(PowerMessage::Set {
1061 lever: *lever,
1062 idx: ctx[self.levers.dds[i]].selected,
1063 }));
1064 }
1065 }
1066 for source in Source::ALL {
1067 let i = source_index(source);
1068 if ctx[self.dd_assign[i]].take_change() {
1069 actions.push(AppAction::Power(PowerMessage::Assign {
1070 source,
1071 idx: ctx[self.dd_assign[i]].selected,
1072 }));
1073 }
1074 }
1075 }
1076 }
1077
1078 #[cfg(test)]
1079 mod tests {
1080 use super::*;
1081 use crate::pages::AppPage;
1082
1083 fn facts() -> PowerFacts {
1084 let mut plan = PowerPlan::default();
1085 plan.put(Mode::Performance, Lever::Profile, Some("performance")).unwrap();
1086 plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
1087 PowerFacts {
1088 battery_present: true,
1089 status: "Discharging".to_string(),
1090 capacity_pct: 92,
1091 health_pct: Some(83),
1092 power_w: Some(7.2),
1093 ac_online: Some(false),
1094 source: Source::Battery,
1095 plan,
1096 automation: Automation::Ready,
1097 energy_now_uwh: Some(44_900_000.0),
1098 energy_full_uwh: Some(74_900_000.0),
1099 vendor: "SMP".to_string(),
1100 model: "5B11M90061".to_string(),
1101 profiles: vec!["low-power".into(), "balanced".into(), "performance".into()],
1102 profile: "balanced".to_string(),
1103 epps: vec!["default".into(), "performance".into(), "balance_power".into(), "power".into()],
1104 epp: "balance_power".to_string(),
1105 charge_limit: Some(80),
1106 charge_start: Some(75),
1107 turbo: Some(true),
1108 governors: vec!["performance".into(), "powersave".into()],
1109 governor: "powersave".to_string(),
1110 gpu_limit_w: Some(80),
1111 gpu_default_w: Some(80),
1112 gpu_min_w: Some(5),
1113 igpu_mhz: Some(1500),
1114 igpu_max_mhz: Some(1500),
1115 igpu_min_mhz: Some(100),
1116 aspm_policies: vec!["default".into(), "performance".into(), "powersave".into()],
1117 aspm: "default".to_string(),
1118 hda_idle_secs: Some(10),
1119 animations: true,
1120 idle_display_off_secs: Some(600),
1121 idle_sleep_secs: Some(1800),
1122 }
1123 }
1124
1125 /// Loaded, editing whichever mode the machine is running — which is what
1126 /// the page opens on.
1127 fn loaded(ui: &mut UiContext) -> PowerState {
1128 let mut st = PowerState::new(ui);
1129 st.loaded = true;
1130 st.facts = facts();
1131 st.editing = st.facts.plan.assigned(st.facts.source);
1132 rebuild_options(&mut st, ui);
1133 st
1134 }
1135
1136 const PROFILE: usize = 0;
1137 const TURBO: usize = 3;
1138 const IGPU: usize = 4;
1139 const AUDIO: usize = 6;
1140 const ANIMATIONS: usize = 8;
1141
1142 #[test]
1143 fn the_lever_section_shows_the_edited_mode_behind_not_set() {
1144 let mut ui = cce_ui::context::UiContext::new();
1145 let st = loaded(&mut ui);
1146 // On battery, so Power Saver is running; its plan says low-power.
1147 assert_eq!(st.editing, Mode::PowerSaver);
1148 assert_eq!(ui[st.dd_mode].options, ["Performance", "Balanced", "Power Saver"]);
1149 assert_eq!(ui[st.dd_mode].selected, 2);
1150 assert_eq!(st.levers.rows[PROFILE], ["", "low-power", "balanced", "performance"]);
1151 assert_eq!(ui[st.levers.dds[PROFILE]].selected, 1);
1152 // Nothing planned for turbo in this mode → Not set.
1153 assert_eq!(ui[st.levers.dds[TURBO]].selected, 0);
1154 assert_eq!(st.levers.rows[TURBO], ["", "on", "off"]);
1155 }
1156
1157 #[test]
1158 fn the_running_mode_reports_the_live_value_on_its_not_set_row() {
1159 let mut ui = cce_ui::context::UiContext::new();
1160 let mut st = loaded(&mut ui);
1161 // Power Saver is running: sysfs says balanced and turbo on, and the
1162 // Not set row is where the page says so.
1163 assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set — now Balanced");
1164 assert_eq!(ui[st.levers.dds[TURBO]].options[0], "Not set — now Enabled");
1165 // A mode that is not running has no live value to report.
1166 st.editing = Mode::Balanced;
1167 rebuild_options(&mut st, &mut ui);
1168 assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set");
1169 assert_eq!(ui[st.levers.dds[PROFILE]].selected, 0);
1170 }
1171
1172 #[test]
1173 fn the_page_opens_on_the_mode_the_machine_is_running() {
1174 let mut ui = cce_ui::context::UiContext::new();
1175 let mut st = PowerState::new(&mut ui);
1176 // Default state edits Balanced; the first read is on battery, which
1177 // runs Power Saver.
1178 assert_eq!(st.editing, Mode::Balanced);
1179 update(&mut st, PowerMessage::Refreshed(facts()), &mut ui);
1180 assert_eq!(st.editing, Mode::PowerSaver);
1181 assert_eq!(ui[st.dd_mode].selected, mode_index(Mode::PowerSaver));
1182 // A later read does not yank the section away from the user's pick.
1183 update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)), &mut ui);
1184 let mut plugged = facts();
1185 plugged.source = Source::Ac;
1186 update(&mut st, PowerMessage::Refreshed(plugged), &mut ui);
1187 assert_eq!(st.editing, Mode::Performance);
1188 }
1189
1190 #[test]
1191 fn switching_the_edited_mode_swaps_the_lever_values() {
1192 let mut ui = cce_ui::context::UiContext::new();
1193 let mut st = loaded(&mut ui);
1194 assert_eq!(ui[st.levers.dds[PROFILE]].selected, 1); // low-power
1195 update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)), &mut ui);
1196 assert_eq!(st.editing, Mode::Performance);
1197 assert_eq!(ui[st.dd_mode].selected, 0);
1198 assert_eq!(ui[st.levers.dds[PROFILE]].selected, 3); // performance
1199 update(&mut st, PowerMessage::EditMode(mode_index(Mode::Balanced)), &mut ui);
1200 assert_eq!(ui[st.levers.dds[PROFILE]].selected, 0); // nothing planned
1201 // Editing is page state: it changes no assignment and no plan.
1202 assert_eq!(st.facts.plan.assigned(Source::Battery), Mode::PowerSaver);
1203 assert_eq!(st.facts.plan.get(Mode::Balanced, Lever::Profile), None);
1204 }
1205
1206 #[test]
1207 fn assignment_dropdowns_follow_the_plan_and_pick_a_mode_per_state() {
1208 let mut ui = cce_ui::context::UiContext::new();
1209 let mut st = loaded(&mut ui);
1210 assert_eq!(ui[st.dd_assign[source_index(Source::Ac)]].selected, mode_index(Mode::Performance));
1211 assert_eq!(ui[st.dd_assign[source_index(Source::Battery)]].selected, mode_index(Mode::PowerSaver));
1212 // Reassigning the live state hands the machine to that mode, and the
1213 // lever section — still editing Power Saver — stops claiming to run.
1214 update(
1215 &mut st,
1216 PowerMessage::Assign { source: Source::Battery, idx: mode_index(Mode::Balanced) },&mut ui);
1217 assert_eq!(st.facts.plan.assigned(Source::Battery), Mode::Balanced);
1218 assert_eq!(ui[st.dd_assign[source_index(Source::Battery)]].selected, mode_index(Mode::Balanced));
1219 assert_eq!(st.editing, Mode::PowerSaver);
1220 assert_eq!(ui[st.levers.dds[PROFILE]].options[0], "Not set");
1221 // Both states may run the same mode.
1222 update(
1223 &mut st,
1224 PowerMessage::Assign { source: Source::Ac, idx: mode_index(Mode::Balanced) },&mut ui);
1225 assert_eq!(st.facts.plan.assigned(Source::Ac), Mode::Balanced);
1226 }
1227
1228 #[test]
1229 fn charge_limit_rows_are_windows_and_map_current_and_off_list_values() {
1230 let mut ui = cce_ui::context::UiContext::new();
1231 let w = |start, end| ChargeLimit { start: Some(start), end: Some(end) };
1232 let mut st = loaded(&mut ui);
1233 // Nothing planned: the battery's own 75/80 is the longevity preset.
1234 assert_eq!(st.limit_values, [w(0, 100), w(75, 80), w(55, 60)]);
1235 assert_eq!(ui[st.dd_limit].selected, 1);
1236 // An off-list window gets its own row instead of a wrong match.
1237 st.facts.charge_limit = Some(70);
1238 st.facts.charge_start = Some(65);
1239 rebuild_options(&mut st, &mut ui);
1240 assert_eq!(st.limit_values[3], w(65, 70));
1241 assert_eq!(ui[st.dd_limit].selected, 3);
1242 assert_eq!(ui[st.dd_limit].options[3], "70% — current, from 65%");
1243 // Once planned, the plan is what the row shows — the firmware
1244 // having forgotten it (0/100) is what the helper puts right.
1245 st.facts.plan.set_charge_limit(w(55, 60)).unwrap();
1246 st.facts.charge_limit = Some(100);
1247 st.facts.charge_start = Some(0);
1248 rebuild_options(&mut st, &mut ui);
1249 assert_eq!(ui[st.dd_limit].selected, 2);
1250 // A battery with no start threshold is offered the ends alone.
1251 st.facts.plan = PowerPlan::default();
1252 st.facts.charge_start = None;
1253 st.facts.charge_limit = Some(80);
1254 rebuild_options(&mut st, &mut ui);
1255 assert_eq!(st.limit_values[1], ChargeLimit { start: None, end: Some(80) });
1256 assert_eq!(ui[st.dd_limit].selected, 1);
1257 }
1258
1259 #[test]
1260 fn a_charge_limit_pick_is_recorded_in_the_plan() {
1261 let mut ui = cce_ui::context::UiContext::new();
1262 let mut st = loaded(&mut ui);
1263 update(&mut st, PowerMessage::SetLimit(0), &mut ui);
1264 assert_eq!(st.facts.plan.charge_limit(), ChargeLimit { start: Some(0), end: Some(100) });
1265 assert_eq!((st.facts.charge_start, st.facts.charge_limit), (Some(0), Some(100)));
1266 assert_eq!(ui[st.dd_limit].selected, 0);
1267 // No mode gained a lever from it.
1268 assert!(Mode::ALL.iter().all(|m| st.facts.plan.levers(*m).all(|(l, _)| l == Lever::Profile)));
1269 }
1270
1271 #[test]
1272 fn open_dropdown_is_left_alone_on_refresh() {
1273 let mut ui = cce_ui::context::UiContext::new();
1274 let mut st = loaded(&mut ui);
1275 ui[st.levers.dds[PROFILE]].open = true;
1276 ui[st.levers.dds[PROFILE]].selected = 2;
1277 let mut newer = facts();
1278 newer.profile = "low-power".to_string();
1279 st.facts = newer;
1280 rebuild_options(&mut st, &mut ui);
1281 // Open menu untouched; the others refreshed.
1282 assert_eq!(ui[st.levers.dds[PROFILE]].selected, 2);
1283 }
1284
1285 #[test]
1286 fn battery_time_is_energy_over_draw_and_follows_direction() {
1287 // sysfs has no time field — this replaces what UPower used to compute
1288 // for the System page's battery section.
1289 let mut f = facts();
1290 f.status = "Discharging".to_string();
1291 f.power_w = Some(50.0);
1292 // 44.9 Wh left at 50 W ≈ 53.9 min.
1293 assert_eq!(humanize_secs(battery_seconds(&f).unwrap()), "53m");
1294
1295 // Charging counts the GAP to full, not what is already in the pack.
1296 f.status = "Charging".to_string();
1297 // (74.9 - 44.9) = 30 Wh at 50 W = 36 min.
1298 assert_eq!(humanize_secs(battery_seconds(&f).unwrap()), "36m");
1299
1300 // Idle on AC, or no draw at all, has no meaningful estimate.
1301 f.status = "Full".to_string();
1302 assert_eq!(battery_seconds(&f), None);
1303 f.status = "Discharging".to_string();
1304 f.power_w = Some(0.0);
1305 assert_eq!(battery_seconds(&f), None);
1306 }
1307
1308 #[test]
1309 fn humanize_secs_splits_hours() {
1310 assert_eq!(humanize_secs(54 * 60), "54m");
1311 assert_eq!(humanize_secs(3 * 3600 + 7 * 60), "3h 7m");
1312 }
1313
1314 #[test]
1315 fn pretty_prints_tokens() {
1316 assert_eq!(pretty("low-power"), "Low Power");
1317 assert_eq!(pretty("balance_performance"), "Balance Performance");
1318 assert_eq!(pretty("default"), "Default");
1319 }
1320
1321 #[test]
1322 fn section_widgets_mirror_presence_gates() {
1323 let mut ui = cce_ui::context::UiContext::new();
1324 let mut st = PowerState::new(&mut ui);
1325 // Not loaded: one section, nothing reported (the view paints only the
1326 // loading line).
1327 assert_eq!(st.section_widgets(), vec![Vec::new()]);
1328 st.loaded = true;
1329 st.facts = facts();
1330 rebuild_options(&mut st, &mut ui);
1331 let counts = |st: &mut PowerState| st.section_widgets().iter().map(Vec::len).collect::<Vec<_>>();
1332 // Every interface present: the charge limit, the mode picker plus all
1333 // eleven levers, and one assignment per adapter state.
1334 assert_eq!(counts(&mut st), [1, 12, 2]);
1335 // A host without a charge-limit knob or turbo file reports fewer.
1336 st.facts.charge_limit = None;
1337 st.facts.turbo = None;
1338 rebuild_options(&mut st, &mut ui);
1339 assert_eq!(counts(&mut st), [0, 11, 2]);
1340 // No cpufreq governors and no NVIDIA driver: both drop out too.
1341 st.facts.governors.clear();
1342 st.facts.gpu_limit_w = None;
1343 st.facts.gpu_default_w = None;
1344 st.facts.gpu_min_w = None;
1345 rebuild_options(&mut st, &mut ui);
1346 assert_eq!(counts(&mut st), [0, 9, 2]);
1347 // No Intel render clocks, an ASPM-less kernel and no snd_hda_intel is
1348 // down to profile, epp, animations and the two idle timeouts — which
1349 // no host lacks.
1350 st.facts.igpu_max_mhz = None;
1351 st.facts.igpu_min_mhz = None;
1352 st.facts.aspm_policies.clear();
1353 st.facts.hda_idle_secs = None;
1354 rebuild_options(&mut st, &mut ui);
1355 assert_eq!(counts(&mut st), [0, 6, 2]);
1356 // A desktop: one adapter state, so there is nothing to assign.
1357 st.facts.battery_present = false;
1358 rebuild_options(&mut st, &mut ui);
1359 assert_eq!(counts(&mut st), [0, 6]);
1360 }
1361
1362 #[test]
1363 fn igpu_rows_come_from_the_hardware_range() {
1364 let mut ui = cce_ui::context::UiContext::new();
1365 let mut st = loaded(&mut ui);
1366 // RP0, the rounded midpoint, RPn — no invented numbers, behind the
1367 // Not set row.
1368 assert_eq!(st.levers.rows[IGPU], ["", "1500", "800", "100"]);
1369 assert_eq!(ui[st.levers.dds[IGPU]].selected, 0);
1370 // A planned cap that is none of the three earns its own row.
1371 st.facts.plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("1300")).unwrap();
1372 rebuild_options(&mut st, &mut ui);
1373 assert_eq!(st.levers.rows[IGPU], ["", "1500", "800", "100", "1300"]);
1374 assert_eq!(ui[st.levers.dds[IGPU]].selected, 4);
1375 assert_eq!(ui[st.levers.dds[IGPU]].options[4], "1300 MHz");
1376 // And a live cap off the list still shows on the Not set row.
1377 st.facts.igpu_mhz = Some(1200);
1378 rebuild_options(&mut st, &mut ui);
1379 assert_eq!(ui[st.levers.dds[IGPU]].options[0], "Not set — now 1200 MHz");
1380 }
1381
1382 #[test]
1383 fn audio_rows_are_the_three_timeouts_behind_not_set() {
1384 let mut ui = cce_ui::context::UiContext::new();
1385 let mut st = loaded(&mut ui);
1386 assert_eq!(st.levers.rows[AUDIO], ["", "0", "1", "10"]);
1387 assert_eq!(ui[st.levers.dds[AUDIO]].options[1], "Never suspend");
1388 assert_eq!(ui[st.levers.dds[AUDIO]].selected, 0);
1389 st.facts.plan.put(Mode::PowerSaver, Lever::AudioIdleSecs, Some("30")).unwrap();
1390 rebuild_options(&mut st, &mut ui);
1391 assert_eq!(st.levers.rows[AUDIO], ["", "0", "1", "10", "30"]);
1392 assert_eq!(ui[st.levers.dds[AUDIO]].options[4], "After 30 s idle");
1393 }
1394
1395 #[test]
1396 fn aspm_rows_come_from_the_kernels_own_list() {
1397 let mut ui = cce_ui::context::UiContext::new();
1398 let mut st = loaded(&mut ui);
1399 let i = lever_index(Lever::Aspm);
1400 st.facts.plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
1401 rebuild_options(&mut st, &mut ui);
1402 // fetch strips the brackets; the selection lands behind Not set.
1403 assert_eq!(st.levers.rows[i], ["", "default", "performance", "powersave"]);
1404 assert_eq!(ui[st.levers.dds[i]].selected, 3);
1405 assert_eq!(ui[st.levers.dds[i]].options[3], "Powersave");
1406 }
1407
1408 #[test]
1409 fn gpu_rows_are_default_and_min_with_an_off_list_live_value() {
1410 let mut ui = cce_ui::context::UiContext::new();
1411 let mut st = loaded(&mut ui);
1412 let i = lever_index(Lever::GpuLimitW);
1413 // Current == default: two rows, no duplicate.
1414 assert_eq!(st.levers.rows[i], ["", "80", "5"]);
1415 assert_eq!(ui[st.levers.dds[i]].selected, 0);
1416 // A live limit that is neither default nor minimum is reported on the
1417 // Not set row rather than silently selecting the wrong one.
1418 st.facts.gpu_limit_w = Some(60);
1419 rebuild_options(&mut st, &mut ui);
1420 assert_eq!(st.levers.rows[i], ["", "80", "5"]);
1421 assert_eq!(ui[st.levers.dds[i]].selected, 0);
1422 assert_eq!(ui[st.levers.dds[i]].options[0], "Not set — now 60 W");
1423 }
1424
1425 #[test]
1426 fn the_three_sections_paint_and_the_assignment_one_drops_on_a_desktop() {
1427 let mut ui = cce_ui::context::UiContext::new();
1428 let paint = |st: &mut PowerState, sections: usize, ui: &mut UiContext| {
1429 let mut layout = cce_ui::layout::PageFlow::new();
1430 let sec_focused = vec![false; sections];
1431 let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, ui);
1432 assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
1433 };
1434 let mut st = loaded(&mut ui);
1435 paint(&mut st, 3, &mut ui);
1436 // A desktop has one adapter state and so nothing to assign.
1437 st.facts.battery_present = false;
1438 rebuild_options(&mut st, &mut ui);
1439 paint(&mut st, 2, &mut ui);
1440 // And the loading gate paints its one line.
1441 let mut empty = PowerState::new(&mut ui);
1442 paint(&mut empty, 1, &mut ui);
1443 }
1444
1445 #[test]
1446 fn a_stale_root_helper_is_named_on_the_page() {
1447 let mut ui = cce_ui::context::UiContext::new();
1448 let lines = |st: &mut PowerState, ui: &mut UiContext| -> Vec<String> {
1449 let mut layout = cce_ui::layout::PageFlow::new();
1450 let sec_focused = vec![false; 3];
1451 let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, ui);
1452 pc.texts.iter().map(|t| t.0.clone()).collect()
1453 };
1454 let mut st = loaded(&mut ui);
1455 assert!(lines(&mut st, &mut ui).iter().any(|l| l.contains("Switches automatically")));
1456 // A helper too old to read a plan with modes applies nothing on plug
1457 // or unplug, and the page says so rather than claiming it switches.
1458 st.facts.automation = Automation::Stale;
1459 let out = lines(&mut st, &mut ui);
1460 assert!(out.iter().any(|l| l.contains("out of date")), "{out:?}");
1461 assert!(out.iter().any(|l| l.contains("ccebuild install-system")), "{out:?}");
1462 st.facts.automation = Automation::Missing;
1463 assert!(lines(&mut st, &mut ui).iter().any(|l| l.contains("not installed")));
1464 }
1465
1466 #[test]
1467 fn animations_is_offered_everywhere_and_reports_the_live_state() {
1468 let mut ui = cce_ui::context::UiContext::new();
1469 let mut st = loaded(&mut ui);
1470 assert_eq!(st.levers.rows[ANIMATIONS], ["", "on", "off"]);
1471 assert_eq!(ui[st.levers.dds[ANIMATIONS]].options, ["Not set — now Enabled", "Enabled", "Disabled"]);
1472 assert_eq!(ui[st.levers.dds[ANIMATIONS]].selected, 0);
1473 // A host with none of the hardware levers still has animations.
1474 st.facts = PowerFacts {
1475 plan: st.facts.plan.clone(),
1476 source: Source::Battery,
1477 animations: false,
1478 idle_display_off_secs: None,
1479 idle_sleep_secs: None,
1480 ..PowerFacts::default()
1481 };
1482 rebuild_options(&mut st, &mut ui);
1483 assert_eq!(ui[st.levers.dds[ANIMATIONS]].options[0], "Not set — now Disabled");
1484 // A planned value is selected like any other lever's.
1485 st.facts.plan.put(Mode::PowerSaver, Lever::Animations, Some("off")).unwrap();
1486 rebuild_options(&mut st, &mut ui);
1487 assert_eq!(ui[st.levers.dds[ANIMATIONS]].selected, 2);
1488 }
1489
1490 #[test]
1491 fn set_live_mirrors_each_lever() {
1492 let mut f = facts();
1493 set_live(Lever::Profile, "performance", &mut f);
1494 set_live(Lever::Turbo, "off", &mut f);
1495 set_live(Lever::IgpuMaxMhz, "800", &mut f);
1496 set_live(Lever::GpuLimitW, "40", &mut f);
1497 set_live(Lever::Animations, "off", &mut f);
1498 assert_eq!(f.profile, "performance");
1499 assert_eq!(f.turbo, Some(false));
1500 assert_eq!(f.igpu_mhz, Some(800));
1501 assert_eq!(f.gpu_limit_w, Some(40));
1502 // Round trip: what set_live wrote is what live() reads back.
1503 assert_eq!(live(Lever::Turbo, &f).as_deref(), Some("off"));
1504 assert_eq!(live(Lever::GpuLimitW, &f).as_deref(), Some("40"));
1505 assert_eq!(live(Lever::Animations, &f).as_deref(), Some("off"));
1506 }
1507
1508 #[test]
1509 fn idle_timeouts_read_the_status_line_and_print_as_durations() {
1510 let line = "display_off=600s sleep=1800s command=\"systemctl suspend\" idle=4s inhibited=false inhibited_by=\"\" displays_off=false sleeping=false\n";
1511 assert_eq!(idle_status_field(line, "display_off"), Some(600));
1512 assert_eq!(idle_status_field(line, "sleep"), Some(1800));
1513 assert_eq!(idle_status_field(line, "idle"), Some(4));
1514 assert_eq!(idle_status_field("garbage", "sleep"), None);
1515 assert_eq!(idle_secs_text("0"), "Never");
1516 assert_eq!(idle_secs_text("90"), "After 90 s");
1517 assert_eq!(idle_secs_text("600"), "After 10 min");
1518 assert_eq!(idle_secs_text("7200"), "After 2 h");
1519 // Both levers are offered on every host, and the live row follows
1520 // the compositor's report.
1521 let f = facts();
1522 assert_eq!(choices(Lever::IdleDisplayOffSecs, &f).len(), IDLE_DISPLAY_OFF_CHOICES.len());
1523 assert_eq!(live(Lever::IdleSleepSecs, &f), Some("1800".to_string()));
1524 }
1525 }