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