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