git.lucas.co / cce-system-interface
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 }