git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commit26f53879b32568e6b26538505c1187c132b843b4
parent1c93243dfa
authorLucas Galante <[email protected]>
date2026-09-22 14:54
feat(power): power modes with a per-adapter-state assignment

The Power page kept two fixed sets of levers, one per power source, edited
side by side. This replaces them with named power modes and a small table
saying which mode each adapter state runs:

- "Power Mode" is one section for every mode, with a dropdown at its top
  choosing which one it edits, so the levers stay in the same place
  whichever mode is on screen. The page opens on the mode the machine is
  actually running.
- "Mode Assignment" is a dropdown per adapter state — Plugged In and On
  Battery — picking the mode it runs. Two states may share a mode, which
  the old shape could not express at all.

Levers are now always the plan's ("Not set" means leave it alone). The live
sysfs value has not gone away: the running mode reports it on its own Not
set row ("Not set — now Balanced"), which is also where the old active
column's live readout went.

The plan file grows `mode "<name>"` blocks and an `assign` block, and the
helper grows `apply-mode` and `assign` alongside a `set` that now names a
mode instead of a source. The pre-modes file still parses: each `ac` /
`battery` block becomes the mode that state runs by default (Performance
and Power Saver), so an existing /etc/cce/power.kdl keeps doing exactly
what it did — verified against this host's own plan.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/bin/cce-power-apply.rs | 118 +++++++---
 src/pages/power.rs         | 564 +++++++++++++++++++++++++++++++--------------
 src/power_plan.rs          | 374 ++++++++++++++++++++++--------
 3 files changed, 751 insertions(+), 305 deletions(-)

diff --git a/src/bin/cce-power-apply.rs b/src/bin/cce-power-apply.rs
index 172525c..041c5b4 100644
--- a/src/bin/cce-power-apply.rs
+++ b/src/bin/cce-power-apply.rs
@@ -1,35 +1,45 @@
-//! cce-power-apply — the root side of the Power page's per-source plan.
+//! cce-power-apply — the root side of the Power page's modes and their
+//! adapter-state assignment.
 //!
-//! The System Interface's Power page keeps one set of levers for "plugged
-//! in" and one for "on battery" (`/etc/cce/power.kdl`, see
-//! `cce_settings::power_plan`). Something has to write those levers into
-//! sysfs as root whenever the charger comes or goes, with no session and no
-//! prompt — that is this binary:
+//! The System Interface's Power page keeps a named set of levers per power
+//! mode plus a table saying which mode runs plugged in and which on battery
+//! (`/etc/cce/power.kdl`, see `cce_settings::power_plan`). Something has to
+//! write those levers into sysfs as root whenever the charger comes or goes,
+//! with no session and no prompt — that is this binary:
 //!
-//! - `apply [ac|battery]` — apply the plan for the current (or the named)
-//!   source. Run by `cce-power-apply.service`, which udev starts when the
-//!   Mains supply appears at boot or flips online/offline
+//! - `apply [ac|battery]` — apply the mode assigned to the current (or the
+//!   named) adapter state. Run by `cce-power-apply.service`, which udev
+//!   starts when the Mains supply appears at boot or flips online/offline
 //!   (`udev/90-cce-power-apply.rules`). Per-lever failures are logged and do
 //!   not fail the run: a missing NVIDIA driver must not hide the CPU profile
 //!   that did land.
-//! - `set <ac|battery> <lever> <value|unset>` — record one lever in the plan
-//!   and, when that source is the live one, apply it now. Run by the Power
-//!   page under pkexec, the app's standard privileged path.
-//! - `show` — print the plan and the live source.
+//! - `apply-mode <mode>` — apply one mode by name, whatever is plugged in.
+//! - `set <mode> <lever> <value|unset>` — record one lever on a mode and,
+//!   when that mode is the one running, apply it now. Run by the Power page
+//!   under pkexec, the app's standard privileged path.
+//! - `assign <ac|battery> <mode>` — point an adapter state at a mode and,
+//!   when that state is the live one, apply the mode now.
+//! - `show` — print the plan and the live adapter state.
 //!
 //! Installed to `/usr/bin` by `ccebuild install-system` (the udev rule and
 //! the unit name that path); `ccebuild install` also drops a copy in
 //! `~/.local/bin`, which the page falls back to under pkexec before the root
 //! side is installed.
 
-use cce_settings::power_plan::{apply_lever, apply_source, current_source, Lever, PowerPlan, Source, PLAN_PATH};
+use cce_settings::power_plan::{
+    apply_lever, apply_mode, current_source, Lever, Mode, PowerPlan, Source, PLAN_PATH,
+};
 
 fn usage() -> ! {
     eprintln!(
         "usage: cce-power-apply apply [ac|battery]\n       \
-                cce-power-apply set <ac|battery> <lever> <value|unset>\n       \
+                cce-power-apply apply-mode <mode>\n       \
+                cce-power-apply set <mode> <lever> <value|unset>\n       \
+                cce-power-apply assign <ac|battery> <mode>\n       \
                 cce-power-apply show\n\
+         modes:  {}\n\
          levers: {}",
+        Mode::ALL.iter().map(|m| m.key()).collect::<Vec<_>>().join(" "),
         Lever::ALL.iter().map(|l| l.key()).collect::<Vec<_>>().join(" ")
     );
     std::process::exit(2)
@@ -42,6 +52,23 @@ fn load_plan() -> Result<PowerPlan, i32> {
     })
 }
 
+/// Apply one mode and report each lever. `what` names what asked for it —
+/// the adapter state, or the mode itself — so the journal says why.
+fn run_mode(plan: &PowerPlan, mode: Mode, what: &str) -> i32 {
+    let results = apply_mode(plan, mode);
+    if results.is_empty() {
+        println!("cce-power-apply: {} runs {}, which sets nothing", what, mode.key());
+        return 0;
+    }
+    for (lever, result) in &results {
+        match result {
+            Ok(()) => println!("{} [{}]: {} = {}", what, mode.key(), lever.key(), plan.get(mode, *lever).unwrap_or("")),
+            Err(e) => eprintln!("cce-power-apply: {} [{}] {}: {}", what, mode.key(), lever.key(), e),
+        }
+    }
+    0
+}
+
 fn cmd_apply(forced: Option<&str>) -> i32 {
     let source = match forced {
         None => current_source(),
@@ -51,30 +78,29 @@ fn cmd_apply(forced: Option<&str>) -> i32 {
         Ok(p) => p,
         Err(code) => return code,
     };
-    let results = apply_source(&plan, source);
-    if results.is_empty() {
-        println!("cce-power-apply: nothing planned for {}", source.key());
-        return 0;
-    }
-    for (lever, result) in &results {
-        match result {
-            Ok(()) => println!("{}: {} = {}", source.key(), lever.key(), plan.get(source, *lever).unwrap_or("")),
-            Err(e) => eprintln!("cce-power-apply: {} {}: {}", source.key(), lever.key(), e),
-        }
-    }
-    0
+    run_mode(&plan, plan.assigned(source), source.key())
+}
+
+fn cmd_apply_mode(rest: &[String]) -> i32 {
+    let [mode] = rest else { usage() };
+    let mode = Mode::parse(mode).unwrap_or_else(|| usage());
+    let plan = match load_plan() {
+        Ok(p) => p,
+        Err(code) => return code,
+    };
+    run_mode(&plan, mode, "mode")
 }
 
 fn cmd_set(rest: &[String]) -> i32 {
-    let [source, lever, value] = rest else { usage() };
-    let source = Source::parse(source).unwrap_or_else(|| usage());
+    let [mode, lever, value] = rest else { usage() };
+    let mode = Mode::parse(mode).unwrap_or_else(|| usage());
     let lever = Lever::parse(lever).unwrap_or_else(|| usage());
     let value: Option<&str> = if value == "unset" { None } else { Some(value.as_str()) };
     let mut plan = match load_plan() {
         Ok(p) => p,
         Err(code) => return code,
     };
-    if let Err(e) = plan.put(source, lever, value) {
+    if let Err(e) = plan.put(mode, lever, value) {
         eprintln!("cce-power-apply: {}", e);
         return 2;
     }
@@ -82,10 +108,12 @@ fn cmd_set(rest: &[String]) -> i32 {
         eprintln!("cce-power-apply: writing {}: {}", PLAN_PATH, e);
         return 1;
     }
-    if source == current_source() {
+    // Only the mode the machine is running right now touches sysfs; editing
+    // any other mode is a plan change and nothing more.
+    if mode == plan.assigned(current_source()) {
         if let Some(v) = value {
             if let Err(e) = apply_lever(lever, v) {
-                eprintln!("cce-power-apply: {} {}: {}", source.key(), lever.key(), e);
+                eprintln!("cce-power-apply: {} {}: {}", mode.key(), lever.key(), e);
                 return 1;
             }
         }
@@ -93,11 +121,33 @@ fn cmd_set(rest: &[String]) -> i32 {
     0
 }
 
+fn cmd_assign(rest: &[String]) -> i32 {
+    let [source, mode] = rest else { usage() };
+    let source = Source::parse(source).unwrap_or_else(|| usage());
+    let mode = Mode::parse(mode).unwrap_or_else(|| usage());
+    let mut plan = match load_plan() {
+        Ok(p) => p,
+        Err(code) => return code,
+    };
+    plan.assign(source, mode);
+    if let Err(e) = plan.save() {
+        eprintln!("cce-power-apply: writing {}: {}", PLAN_PATH, e);
+        return 1;
+    }
+    // Reassigning the live adapter state hands the machine to another mode
+    // now, not at the next unplug.
+    if source == current_source() {
+        return run_mode(&plan, mode, source.key());
+    }
+    0
+}
+
 fn cmd_show() -> i32 {
     match load_plan() {
         Ok(plan) => {
             print!("{}", plan.to_kdl());
-            println!("// live source: {}", current_source().key());
+            let source = current_source();
+            println!("// live: {} running {}", source.key(), plan.assigned(source).key());
             0
         }
         Err(code) => code,
@@ -108,7 +158,9 @@ fn main() {
     let args: Vec<String> = std::env::args().skip(1).collect();
     let code = match args.first().map(String::as_str) {
         Some("apply") => cmd_apply(args.get(1).map(String::as_str)),
+        Some("apply-mode") => cmd_apply_mode(&args[1..]),
         Some("set") => cmd_set(&args[1..]),
+        Some("assign") => cmd_assign(&args[1..]),
         Some("show") => cmd_show(),
         _ => usage(),
     };
diff --git a/src/pages/power.rs b/src/pages/power.rs
index f9fde08..ca8a27e 100644
--- a/src/pages/power.rs
+++ b/src/pages/power.rs
@@ -1,6 +1,6 @@
 //! Power: battery facts plus the host's real battery-life levers, all sysfs,
-//! kept as two plans — one for when the machine is plugged in, one for when
-//! it runs on battery.
+//! organized as named power modes and an assignment of a mode to each power
+//! adapter state.
 //!
 //! Every control is discovered from the interfaces this machine actually
 //! exposes (missing ones render as absent, not as dead widgets):
@@ -10,21 +10,26 @@
 //!   charge at 80% is the classic battery-longevity lever
 //! - `/sys/devices/system/cpu/intel_pstate/no_turbo` — turbo boost
 //!
-//! The page is three sections: the battery itself (facts and the charge
-//! limit, which is a charging policy and so not per source), then one column
-//! of levers per power source. The column for the source that is active right
-//! now shows the LIVE sysfs values and a pick there applies immediately; the
-//! other column shows what is planned for that source, with a "Not set" row
-//! meaning "leave it alone". Both are remembered in the plan
+//! The page is three sections. **Battery** is the pack itself: its facts and
+//! the charge limit, which is a charging policy and so belongs to no mode.
+//! **Power Mode** edits one mode's levers, chosen by the dropdown at the top
+//! of the section — one section rather than one per mode, so the levers sit
+//! in the same place whichever mode is being edited. **Mode Assignment**
+//! says which mode runs plugged in and which on battery, one dropdown per
+//! adapter state.
+//!
+//! A "Not set" row means "leave that lever alone"; when the edited mode is
+//! the one running right now, the row also reports the live sysfs value, and
+//! a pick applies immediately. Everything is remembered in the plan
 //! (`crate::power_plan`, `/etc/cce/power.kdl`) through `cce-power-apply`
 //! under pkexec — the one-prompt path every privileged action in this app
-//! takes — and the same helper re-applies the plan from udev when the charger
-//! comes or goes. The UI is optimistic and the 5s watcher re-reads the truth,
-//! so a dismissed auth prompt reverts the dropdown — honest, with no extra
-//! error channel.
+//! takes — and the same helper re-applies the assigned mode from udev when
+//! the charger comes or goes. The UI is optimistic and the 5s watcher
+//! re-reads the truth, so a dismissed auth prompt reverts the dropdown —
+//! honest, with no extra error channel.
 
 use crate::app::{AppAction, PageContent};
-use crate::power_plan::{self, Lever, PowerPlan, Source};
+use crate::power_plan::{self, Lever, Mode, PowerPlan, Source};
 use cce_ui::layout::{LayoutStrategy, PageLayoutBuilder};
 use cce_ui::widget::{Adapted, Dropdown, WidgetHost};
 use std::path::{Path, PathBuf};
@@ -95,22 +100,23 @@ pub struct PowerFacts {
     pub gpu_min_w: Option<u32>,
 }
 
-/// One power source's column of lever dropdowns.
+
+/// The edited mode's column of lever dropdowns. One set, not one per mode:
+/// the mode dropdown above it decides whose values it is showing.
 #[derive(Debug, Clone)]
-pub struct LeverColumn {
-    pub source: Source,
+pub struct LeverSet {
     /// One dropdown per [`Lever::ALL`] entry, in that order.
     pub dds: Vec<Adapted<Dropdown>>,
     /// The plan value behind each row of each dropdown (options are display
-    /// text). Empty string is the "Not set" row; an empty Vec means the
-    /// interface is absent on this host and the dropdown is not painted.
+    /// text). Row 0 is always the "Not set" row and holds the empty string;
+    /// an empty Vec means the interface is absent on this host and the
+    /// dropdown is not painted.
     pub rows: Vec<Vec<String>>,
 }
 
-impl LeverColumn {
-    fn new(source: Source) -> Self {
+impl Default for LeverSet {
+    fn default() -> Self {
         Self {
-            source,
             dds: Lever::ALL
                 .iter()
                 .map(|l| Dropdown::new(vec!["—".to_string()], 0).with_label(l.label()))
@@ -131,8 +137,13 @@ pub struct PowerState {
     pub dd_limit: Adapted<Dropdown>,
     /// Sysfs value per charge-limit dropdown row (options are display text).
     pub limit_values: Vec<u32>,
-    pub ac: LeverColumn,
-    pub battery: LeverColumn,
+    /// Which mode the lever section is editing. Page state, not plan state:
+    /// it says what is on screen, never what the machine runs.
+    pub editing: Mode,
+    pub dd_mode: Adapted<Dropdown>,
+    pub levers: LeverSet,
+    /// One mode picker per [`Source::ALL`] entry, in that order.
+    pub dd_assign: Vec<Adapted<Dropdown>>,
 }
 
 impl Default for PowerState {
@@ -142,34 +153,46 @@ impl Default for PowerState {
             facts: PowerFacts::default(),
             dd_limit: Dropdown::new(vec!["—".to_string()], 0).with_label("Battery Charge Limit"),
             limit_values: Vec::new(),
-            ac: LeverColumn::new(Source::Ac),
-            battery: LeverColumn::new(Source::Battery),
+            editing: Mode::default(),
+            dd_mode: Dropdown::new(mode_options(), 0).with_label("Mode"),
+            levers: LeverSet::default(),
+            dd_assign: Source::ALL
+                .iter()
+                .map(|s| Dropdown::new(mode_options(), 0).with_label(s.label()))
+                .collect(),
         }
     }
 }
 
-impl PowerState {
-    pub fn column_mut(&mut self, source: Source) -> &mut LeverColumn {
-        match source {
-            Source::Ac => &mut self.ac,
-            Source::Battery => &mut self.battery,
-        }
-    }
+fn mode_options() -> Vec<String> {
+    Mode::ALL.iter().map(|m| m.label().to_string()).collect()
 }
 
-/// Which lever columns the page shows. A host with no battery has one power
-/// source, so the battery column would be a plan for a state it never enters.
-fn columns_shown(f: &PowerFacts) -> Vec<Source> {
+/// Which adapter states the assignment section offers. A host with no
+/// battery has one, so the battery row would be an assignment for a state it
+/// never enters.
+fn sources_shown(f: &PowerFacts) -> Vec<Source> {
     if f.battery_present { vec![Source::Ac, Source::Battery] } else { vec![Source::Ac] }
 }
 
+/// Whether the assignment section is worth painting at all — on a host with
+/// a single adapter state there is nothing to choose between.
+fn assignment_shown(f: &PowerFacts) -> bool {
+    sources_shown(f).len() > 1
+}
+
 #[derive(Debug, Clone)]
 pub enum PowerMessage {
     Refreshed(PowerFacts),
     /// Charge-limit pick, by option index.
     SetLimit(usize),
-    /// A lever pick in one source's column, by option index.
-    Set { source: Source, lever: Lever, idx: usize },
+    /// Which mode the lever section edits, by option index. Page-local: it
+    /// writes nothing and applies nothing.
+    EditMode(usize),
+    /// A lever pick on the mode being edited, by option index.
+    Set { lever: Lever, idx: usize },
+    /// Which mode an adapter state runs, by option index.
+    Assign { source: Source, idx: usize },
 }
 
 /// Root action via pkexec, the app's standard privileged path. Detached: the
@@ -470,47 +493,57 @@ fn set_live(lever: Lever, value: &str, f: &mut PowerFacts) {
     }
 }
 
-/// Rebuild one column's dropdowns. The active source's column shows the live
-/// value; the other shows the plan, behind a leading "Not set" row. Either
-/// way a shown value missing from the host's list gets appended as its own
-/// row. Skipped per dropdown while it is open (the default_apps rule: never
-/// yank an open menu out from under the pointer — the next refresh
-/// normalizes it).
-fn fill_column(col: &mut LeverColumn, f: &PowerFacts, active: bool) {
+/// Rebuild the lever dropdowns for the mode being edited. Every lever leads
+/// with a "Not set" row meaning "leave it alone"; when the edited mode is
+/// the one running right now that row also names the live sysfs value, which
+/// is where the page reports what the machine is actually doing. A planned
+/// value missing from the host's list gets appended as its own row. Skipped
+/// per dropdown while it is open (the default_apps rule: never yank an open
+/// menu out from under the pointer — the next refresh normalizes it).
+fn fill_levers(levers: &mut LeverSet, f: &PowerFacts, mode: Mode) {
+    let running = mode == f.plan.assigned(f.source);
     for (i, lever) in Lever::ALL.iter().enumerate() {
-        if col.dds[i].open {
+        if levers.dds[i].open {
             continue;
         }
         let mut rows = choices(*lever, f);
         if rows.is_empty() {
-            col.rows[i].clear();
-            col.dds[i].options = vec!["—".to_string()];
-            col.dds[i].selected = 0;
+            levers.rows[i].clear();
+            levers.dds[i].options = vec!["—".to_string()];
+            levers.dds[i].selected = 0;
             continue;
         }
-        let shown: Option<String> =
-            if active { live(*lever, f) } else { f.plan.get(col.source, *lever).map(str::to_string) };
-        if let Some(s) = &shown {
+        let planned: Option<String> = f.plan.get(mode, *lever).map(str::to_string);
+        if let Some(s) = &planned {
             if !rows.iter().any(|(v, _)| v == s) {
                 rows.push((s.clone(), display_of(*lever, s)));
             }
         }
         let mut values = Vec::with_capacity(rows.len() + 1);
         let mut options = Vec::with_capacity(rows.len() + 1);
-        if !active {
-            values.push(String::new());
-            options.push("Not set".to_string());
-        }
+        values.push(String::new());
+        options.push(match live(*lever, f) {
+            Some(cur) if running => format!("Not set — now {}", display_of(*lever, &cur)),
+            _ => "Not set".to_string(),
+        });
         for (v, d) in rows {
             values.push(v);
             options.push(d);
         }
-        col.dds[i].selected = shown.and_then(|s| values.iter().position(|v| *v == s)).unwrap_or(0);
-        col.dds[i].options = options;
-        col.rows[i] = values;
+        levers.dds[i].selected = planned.and_then(|s| values.iter().position(|v| *v == s)).unwrap_or(0);
+        levers.dds[i].options = options;
+        levers.rows[i] = values;
     }
 }
 
+fn mode_index(mode: Mode) -> usize {
+    Mode::ALL.iter().position(|m| *m == mode).unwrap()
+}
+
+fn source_index(source: Source) -> usize {
+    Source::ALL.iter().position(|s| *s == source).unwrap()
+}
+
 /// Rebuild every dropdown's options/selection from fresh facts.
 fn rebuild_options(state: &mut PowerState) {
     let f = &state.facts;
@@ -536,11 +569,28 @@ fn rebuild_options(state: &mut PowerState) {
             .unwrap_or(0);
         state.limit_values = values;
     }
-    let active = f.source;
-    fill_column(&mut state.ac, f, active == Source::Ac);
-    fill_column(&mut state.battery, f, active == Source::Battery);
+    if !state.dd_mode.open {
+        state.dd_mode.options = mode_options();
+        state.dd_mode.selected = mode_index(state.editing);
+    }
+    fill_levers(&mut state.levers, &state.facts, state.editing);
+    for source in Source::ALL {
+        let i = source_index(source);
+        if state.dd_assign[i].open {
+            continue;
+        }
+        state.dd_assign[i].options = mode_options();
+        state.dd_assign[i].selected = mode_index(state.facts.plan.assigned(source));
+    }
 }
 
+/// How the page says when a mode runs, in a sentence.
+fn when_text(source: Source) -> &'static str {
+    match source {
+        Source::Ac => "plugged in",
+        Source::Battery => "unplugged",
+    }
+}
 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 {
     let mut final_pc = PageContent::new();
     let sec_w = 320.0f32;
@@ -554,9 +604,12 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
         return final_pc;
     }
 
-    let PowerState { facts, dd_limit, ac, battery, .. } = state;
-    let sources = columns_shown(facts);
-    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1 + sources.len());
+    let PowerState { facts, dd_limit, editing, dd_mode, levers, dd_assign, .. } = state;
+    let editing = *editing;
+    let sources = sources_shown(facts);
+    let show_assign = assignment_shown(facts);
+    let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w)
+        .with_section_count(if show_assign { 3 } else { 2 });
 
     // ── Battery: facts, the charge limit, and whether switching is wired up ──
     builder.add_section(&mut final_pc, "Battery", focused(0), |sec| {
@@ -629,35 +682,56 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
         }
     });
 
-    // ── One column of levers per power source ──
-    for (k, source) in sources.iter().enumerate() {
-        let source = *source;
-        let active = source == facts.source;
-        let label = if facts.battery_present { source.label() } else { "Settings" };
-        let col: &mut LeverColumn = match source {
-            Source::Ac => &mut *ac,
-            Source::Battery => &mut *battery,
-        };
+    // ── The edited mode's levers, behind the picker that chooses it ──
+    {
         let f = &*facts;
-        builder.add_section(&mut final_pc, label, focused(1 + k), |sec| {
+        let running = f.plan.assigned(f.source) == editing;
+        let applies_when: Vec<&str> = sources
+            .iter()
+            .filter(|s| f.plan.assigned(**s) == editing)
+            .map(|s| when_text(*s))
+            .collect();
+        builder.add_section(&mut final_pc, "Power Mode", focused(1), |sec| {
             let sec_w = sec.cw;
-            if f.battery_present {
-                if active {
-                    sec.text("Active now — picks apply immediately.", 12.0, 0.0, 11.0, GOOD);
-                } else {
-                    let when = if source == Source::Battery { "unplugged" } else { "plugged in" };
-                    sec.text(&format!("Applied when {}.", when), 12.0, 0.0, 11.0, TEXT_DIM);
-                    sec.text("Not set leaves a lever alone.", 12.0, 0.0, 11.0, TEXT_DIM);
-                }
-                sec.spacing(6.0);
+            {
+                let mut stack = sec.vstack(8.0);
+                dd_mode.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
+                stack.add_widget(dd_mode, sec_w - 28.0, 44.0, ctx);
             }
+            sec.spacing(4.0);
+            if running {
+                sec.text("Running now — picks apply immediately.", 12.0, 0.0, 11.0, GOOD);
+            } else if applies_when.is_empty() {
+                sec.text("Assigned to no adapter state.", 12.0, 0.0, 11.0, TEXT_DIM);
+            } else {
+                sec.text(&format!("Applied when {}.", applies_when.join(" and ")), 12.0, 0.0, 11.0, TEXT_DIM);
+            }
+            sec.text("Not set leaves a lever alone.", 12.0, 0.0, 11.0, TEXT_DIM);
+            sec.spacing(6.0);
             let mut stack = sec.vstack(8.0);
             for i in 0..Lever::ALL.len() {
-                if col.rows[i].is_empty() {
+                if levers.rows[i].is_empty() {
                     continue;
                 }
-                col.dds[i].set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
-                stack.add_widget(&mut col.dds[i], sec_w - 28.0, 44.0, ctx);
+                levers.dds[i].set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
+                stack.add_widget(&mut levers.dds[i], sec_w - 28.0, 44.0, ctx);
+            }
+        });
+    }
+
+    // ── Which mode each power adapter state runs ──
+    if show_assign {
+        let f = &*facts;
+        builder.add_section(&mut final_pc, "Mode Assignment", focused(2), |sec| {
+            let sec_w = sec.cw;
+            sec.text("Which mode runs in each adapter state.", 12.0, 0.0, 11.0, TEXT_DIM);
+            sec.text(&format!("{} right now.", f.source.label()), 12.0, 0.0, 11.0, GOOD);
+            sec.spacing(6.0);
+            let mut stack = sec.vstack(8.0);
+            for source in sources.iter().copied() {
+                let dd = &mut dd_assign[source_index(source)];
+                dd.set_row_rect(stack.context.left + 14.0, sec_w - 28.0);
+                stack.add_widget(dd, sec_w - 28.0, 44.0, ctx);
             }
         });
     }
@@ -668,6 +742,13 @@ pub fn view(state: &mut PowerState, cx: f32, cy: f32, cw: f32, ch: f32, _root_fo
 pub fn update(state: &mut PowerState, msg: PowerMessage) {
     match msg {
         PowerMessage::Refreshed(facts) => {
+            // The page opens on the mode the machine is actually running, so
+            // the first thing on screen describes the present rather than a
+            // mode nothing is using. Only the first read moves it — after
+            // that the pick is the user's.
+            if !state.loaded {
+                state.editing = facts.plan.assigned(facts.source);
+            }
             state.loaded = true;
             if state.facts != facts {
                 state.facts = facts;
@@ -685,9 +766,17 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
                 }
             }
         }
-        PowerMessage::Set { source, lever, idx } => {
+        PowerMessage::EditMode(idx) => {
+            // Page-local: switching which mode is on screen writes nothing
+            // and applies nothing, so it needs no privileged call.
+            if let Some(mode) = Mode::ALL.get(idx).copied() {
+                state.editing = mode;
+                rebuild_options(state);
+            }
+        }
+        PowerMessage::Set { lever, idx } => {
             let i = lever_index(lever);
-            let Some(value) = state.column_mut(source).rows.get(i).and_then(|r| r.get(idx)).cloned() else {
+            let Some(value) = state.levers.rows.get(i).and_then(|r| r.get(idx)).cloned() else {
                 return;
             };
             let value: Option<&str> = if value.is_empty() { None } else { Some(value.as_str()) };
@@ -696,35 +785,64 @@ pub fn update(state: &mut PowerState, msg: PowerMessage) {
             if value.is_some_and(|v| !lever.value_ok(v)) {
                 return;
             }
+            let mode = state.editing;
             let Some(helper) = helper_path() else {
                 log::error!("[power] cce-power-apply not found at {} or beside this binary", power_plan::HELPER_SYSTEM_PATH);
                 return;
             };
             // Detached, like run_privileged: the helper records the pick and,
-            // when this is the live source, applies it; the watcher's next
-            // read reports what actually happened.
+            // when this mode is the running one, applies it; the watcher's
+            // next read reports what actually happened.
             let _ = std::process::Command::new("pkexec")
                 .arg(&helper)
                 .arg("set")
-                .arg(source.key())
+                .arg(mode.key())
                 .arg(lever.key())
                 .arg(value.unwrap_or("unset"))
                 .spawn();
             // Optimistic mirror of what the helper will make true.
-            let _ = state.facts.plan.put(source, lever, value);
-            if source == state.facts.source {
+            let _ = state.facts.plan.put(mode, lever, value);
+            if mode == state.facts.plan.assigned(state.facts.source) {
                 if let Some(v) = value {
                     set_live(lever, v, &mut state.facts);
                 }
             }
+            rebuild_options(state);
+        }
+        PowerMessage::Assign { source, idx } => {
+            let Some(mode) = Mode::ALL.get(idx).copied() else {
+                return;
+            };
+            let Some(helper) = helper_path() else {
+                log::error!("[power] cce-power-apply not found at {} or beside this binary", power_plan::HELPER_SYSTEM_PATH);
+                return;
+            };
+            let _ = std::process::Command::new("pkexec")
+                .arg(&helper)
+                .arg("assign")
+                .arg(source.key())
+                .arg(mode.key())
+                .spawn();
+            state.facts.plan.assign(source, mode);
+            // Reassigning the live state hands the machine to a different
+            // mode; mirror its levers so the page agrees with what the helper
+            // is applying until the watcher's next read.
+            if source == state.facts.source {
+                let values: Vec<(Lever, String)> =
+                    state.facts.plan.levers(mode).map(|(l, v)| (l, v.to_string())).collect();
+                for (lever, value) in values {
+                    set_live(lever, &value, &mut state.facts);
+                }
+            }
+            rebuild_options(state);
         }
     }
 }
 
 impl crate::pages::AppPage for PowerState {
-    // Sections: [Battery, <one per shown source>] — ids mirror the view's
-    // load gate AND its per-interface presence gates (the d13a901 lesson:
-    // never report a widget the view didn't paint).
+    // Sections: [Battery, Power Mode, Mode Assignment] — ids mirror the
+    // view's load gate AND its per-interface presence gates (the d13a901
+    // lesson: never report a widget the view didn't paint).
     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
         if !self.loaded {
             return vec![Vec::new()];
@@ -735,11 +853,17 @@ impl crate::pages::AppPage for PowerState {
             first.push(self.dd_limit.id());
         }
         out.push(first);
-        for source in columns_shown(&self.facts) {
-            let col = self.column_mut(source);
-            let ids = (0..Lever::ALL.len())
-                .filter(|i| !col.rows[*i].is_empty())
-                .map(|i| col.dds[i].id())
+        let mut mode_sec = vec![self.dd_mode.id()];
+        mode_sec.extend(
+            (0..Lever::ALL.len())
+                .filter(|i| !self.levers.rows[*i].is_empty())
+                .map(|i| self.levers.dds[i].id()),
+        );
+        out.push(mode_sec);
+        if assignment_shown(&self.facts) {
+            let ids = sources_shown(&self.facts)
+                .into_iter()
+                .map(|s| self.dd_assign[source_index(s)].id())
                 .collect();
             out.push(ids);
         }
@@ -764,15 +888,24 @@ impl crate::pages::AppPage for PowerState {
         if self.dd_limit.take_change() {
             actions.push(AppAction::Power(PowerMessage::SetLimit(self.dd_limit.selected)));
         }
-        for col in [&mut self.ac, &mut self.battery] {
-            for (i, lever) in Lever::ALL.iter().enumerate() {
-                if col.dds[i].take_change() {
-                    actions.push(AppAction::Power(PowerMessage::Set {
-                        source: col.source,
-                        lever: *lever,
-                        idx: col.dds[i].selected,
-                    }));
-                }
+        if self.dd_mode.take_change() {
+            actions.push(AppAction::Power(PowerMessage::EditMode(self.dd_mode.selected)));
+        }
+        for (i, lever) in Lever::ALL.iter().enumerate() {
+            if self.levers.dds[i].take_change() {
+                actions.push(AppAction::Power(PowerMessage::Set {
+                    lever: *lever,
+                    idx: self.levers.dds[i].selected,
+                }));
+            }
+        }
+        for source in Source::ALL {
+            let i = source_index(source);
+            if self.dd_assign[i].take_change() {
+                actions.push(AppAction::Power(PowerMessage::Assign {
+                    source,
+                    idx: self.dd_assign[i].selected,
+                }));
             }
         }
     }
@@ -785,8 +918,8 @@ mod tests {
 
     fn facts() -> PowerFacts {
         let mut plan = PowerPlan::default();
-        plan.put(Source::Ac, Lever::Profile, Some("performance")).unwrap();
-        plan.put(Source::Battery, Lever::Profile, Some("low-power")).unwrap();
+        plan.put(Mode::Performance, Lever::Profile, Some("performance")).unwrap();
+        plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
         PowerFacts {
             battery_present: true,
             status: "Discharging".to_string(),
@@ -821,10 +954,13 @@ mod tests {
         }
     }
 
+    /// Loaded, editing whichever mode the machine is running — which is what
+    /// the page opens on.
     fn loaded() -> PowerState {
         let mut st = PowerState::default();
         st.loaded = true;
         st.facts = facts();
+        st.editing = st.facts.plan.assigned(st.facts.source);
         rebuild_options(&mut st);
         st
     }
@@ -835,32 +971,86 @@ mod tests {
     const AUDIO: usize = 6;
 
     #[test]
-    fn active_column_shows_live_values_without_a_not_set_row() {
+    fn the_lever_section_shows_the_edited_mode_behind_not_set() {
         let st = loaded();
-        // On battery: the battery column is the live one. The plan says
-        // low-power for it, but sysfs says balanced, and sysfs is what shows.
-        assert_eq!(st.battery.dds[PROFILE].options, ["Low Power", "Balanced", "Performance"]);
-        assert_eq!(st.battery.dds[PROFILE].selected, 1);
-        assert_eq!(st.battery.rows[PROFILE], ["low-power", "balanced", "performance"]);
-        assert_eq!(st.battery.dds[TURBO].selected, 0); // enabled
-        assert_eq!(st.battery.rows[TURBO], ["on", "off"]);
+        // On battery, so Power Saver is running; its plan says low-power.
+        assert_eq!(st.editing, Mode::PowerSaver);
+        assert_eq!(st.dd_mode.options, ["Performance", "Balanced", "Power Saver"]);
+        assert_eq!(st.dd_mode.selected, 2);
+        assert_eq!(st.levers.rows[PROFILE], ["", "low-power", "balanced", "performance"]);
+        assert_eq!(st.levers.dds[PROFILE].selected, 1);
+        // Nothing planned for turbo in this mode → Not set.
+        assert_eq!(st.levers.dds[TURBO].selected, 0);
+        assert_eq!(st.levers.rows[TURBO], ["", "on", "off"]);
     }
 
     #[test]
-    fn inactive_column_shows_the_plan_behind_not_set() {
+    fn the_running_mode_reports_the_live_value_on_its_not_set_row() {
         let mut st = loaded();
-        assert_eq!(st.ac.dds[PROFILE].options, ["Not set", "Low Power", "Balanced", "Performance"]);
-        assert_eq!(st.ac.dds[PROFILE].selected, 3); // planned: performance
-        assert_eq!(st.ac.rows[PROFILE][0], ""); // the unset row
-        // Nothing planned for AC turbo → Not set.
-        assert_eq!(st.ac.dds[TURBO].selected, 0);
-        // Plugging in swaps which column is live.
-        st.facts.source = Source::Ac;
+        // Power Saver is running: sysfs says balanced and turbo on, and the
+        // Not set row is where the page says so.
+        assert_eq!(st.levers.dds[PROFILE].options[0], "Not set — now Balanced");
+        assert_eq!(st.levers.dds[TURBO].options[0], "Not set — now Enabled");
+        // A mode that is not running has no live value to report.
+        st.editing = Mode::Balanced;
         rebuild_options(&mut st);
-        assert_eq!(st.ac.dds[PROFILE].options, ["Low Power", "Balanced", "Performance"]);
-        assert_eq!(st.ac.dds[PROFILE].selected, 1);
-        assert_eq!(st.battery.dds[PROFILE].options.len(), 4);
-        assert_eq!(st.battery.dds[PROFILE].selected, 1); // planned: low-power, after Not set
+        assert_eq!(st.levers.dds[PROFILE].options[0], "Not set");
+        assert_eq!(st.levers.dds[PROFILE].selected, 0);
+    }
+
+    #[test]
+    fn the_page_opens_on_the_mode_the_machine_is_running() {
+        let mut st = PowerState::default();
+        // Default state edits Balanced; the first read is on battery, which
+        // runs Power Saver.
+        assert_eq!(st.editing, Mode::Balanced);
+        update(&mut st, PowerMessage::Refreshed(facts()));
+        assert_eq!(st.editing, Mode::PowerSaver);
+        assert_eq!(st.dd_mode.selected, mode_index(Mode::PowerSaver));
+        // A later read does not yank the section away from the user's pick.
+        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)));
+        let mut plugged = facts();
+        plugged.source = Source::Ac;
+        update(&mut st, PowerMessage::Refreshed(plugged));
+        assert_eq!(st.editing, Mode::Performance);
+    }
+
+    #[test]
+    fn switching_the_edited_mode_swaps_the_lever_values() {
+        let mut st = loaded();
+        assert_eq!(st.levers.dds[PROFILE].selected, 1); // low-power
+        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Performance)));
+        assert_eq!(st.editing, Mode::Performance);
+        assert_eq!(st.dd_mode.selected, 0);
+        assert_eq!(st.levers.dds[PROFILE].selected, 3); // performance
+        update(&mut st, PowerMessage::EditMode(mode_index(Mode::Balanced)));
+        assert_eq!(st.levers.dds[PROFILE].selected, 0); // nothing planned
+        // Editing is page state: it changes no assignment and no plan.
+        assert_eq!(st.facts.plan.assigned(Source::Battery), Mode::PowerSaver);
+        assert_eq!(st.facts.plan.get(Mode::Balanced, Lever::Profile), None);
+    }
+
+    #[test]
+    fn assignment_dropdowns_follow_the_plan_and_pick_a_mode_per_state() {
+        let mut st = loaded();
+        assert_eq!(st.dd_assign[source_index(Source::Ac)].selected, mode_index(Mode::Performance));
+        assert_eq!(st.dd_assign[source_index(Source::Battery)].selected, mode_index(Mode::PowerSaver));
+        // Reassigning the live state hands the machine to that mode, and the
+        // lever section — still editing Power Saver — stops claiming to run.
+        update(
+            &mut st,
+            PowerMessage::Assign { source: Source::Battery, idx: mode_index(Mode::Balanced) },
+        );
+        assert_eq!(st.facts.plan.assigned(Source::Battery), Mode::Balanced);
+        assert_eq!(st.dd_assign[source_index(Source::Battery)].selected, mode_index(Mode::Balanced));
+        assert_eq!(st.editing, Mode::PowerSaver);
+        assert_eq!(st.levers.dds[PROFILE].options[0], "Not set");
+        // Both states may run the same mode.
+        update(
+            &mut st,
+            PowerMessage::Assign { source: Source::Ac, idx: mode_index(Mode::Balanced) },
+        );
+        assert_eq!(st.facts.plan.assigned(Source::Ac), Mode::Balanced);
     }
 
     #[test]
@@ -879,14 +1069,14 @@ mod tests {
     #[test]
     fn open_dropdown_is_left_alone_on_refresh() {
         let mut st = loaded();
-        st.battery.dds[PROFILE].open = true;
-        st.battery.dds[PROFILE].selected = 2;
+        st.levers.dds[PROFILE].open = true;
+        st.levers.dds[PROFILE].selected = 2;
         let mut newer = facts();
         newer.profile = "low-power".to_string();
         st.facts = newer;
         rebuild_options(&mut st);
         // Open menu untouched; the others refreshed.
-        assert_eq!(st.battery.dds[PROFILE].selected, 2);
+        assert_eq!(st.levers.dds[PROFILE].selected, 2);
     }
 
     #[test]
@@ -935,21 +1125,21 @@ mod tests {
         st.facts = facts();
         rebuild_options(&mut st);
         let counts = |st: &mut PowerState| st.section_widgets().iter().map(Vec::len).collect::<Vec<_>>();
-        // Every interface present: the charge limit, then all eight levers in
-        // each of the two source columns.
-        assert_eq!(counts(&mut st), [1, 8, 8]);
+        // Every interface present: the charge limit, the mode picker plus all
+        // eight levers, and one assignment per adapter state.
+        assert_eq!(counts(&mut st), [1, 9, 2]);
         // A host without a charge-limit knob or turbo file reports fewer.
         st.facts.charge_limit = None;
         st.facts.turbo = None;
         rebuild_options(&mut st);
-        assert_eq!(counts(&mut st), [0, 7, 7]);
+        assert_eq!(counts(&mut st), [0, 8, 2]);
         // No cpufreq governors and no NVIDIA driver: both drop out too.
         st.facts.governors.clear();
         st.facts.gpu_limit_w = None;
         st.facts.gpu_default_w = None;
         st.facts.gpu_min_w = None;
         rebuild_options(&mut st);
-        assert_eq!(counts(&mut st), [0, 5, 5]);
+        assert_eq!(counts(&mut st), [0, 6, 2]);
         // No Intel render clocks, an ASPM-less kernel and no snd_hda_intel is
         // down to profile and epp.
         st.facts.igpu_max_mhz = None;
@@ -957,69 +1147,90 @@ mod tests {
         st.facts.aspm_policies.clear();
         st.facts.hda_idle_secs = None;
         rebuild_options(&mut st);
-        assert_eq!(counts(&mut st), [0, 2, 2]);
-        // A desktop: no battery, so only one source column exists.
+        assert_eq!(counts(&mut st), [0, 3, 2]);
+        // A desktop: one adapter state, so there is nothing to assign.
         st.facts.battery_present = false;
         rebuild_options(&mut st);
-        assert_eq!(counts(&mut st), [0, 2]);
+        assert_eq!(counts(&mut st), [0, 3]);
     }
 
     #[test]
     fn igpu_rows_come_from_the_hardware_range() {
         let mut st = loaded();
-        // RP0, the rounded midpoint, RPn — no invented numbers; the live column
-        // has no Not set row.
-        assert_eq!(st.battery.rows[IGPU], ["1500", "800", "100"]);
-        assert_eq!(st.battery.dds[IGPU].selected, 0);
-        // A live cap that is none of the three earns its own row.
-        st.facts.igpu_mhz = Some(1200);
+        // RP0, the rounded midpoint, RPn — no invented numbers, behind the
+        // Not set row.
+        assert_eq!(st.levers.rows[IGPU], ["", "1500", "800", "100"]);
+        assert_eq!(st.levers.dds[IGPU].selected, 0);
+        // A planned cap that is none of the three earns its own row.
+        st.facts.plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("1300")).unwrap();
         rebuild_options(&mut st);
-        assert_eq!(st.battery.rows[IGPU], ["1500", "800", "100", "1200"]);
-        assert_eq!(st.battery.dds[IGPU].selected, 3);
-        assert_eq!(st.battery.dds[IGPU].options[3], "1200 MHz");
-        // And so does a planned value in the other column.
-        st.facts.plan.put(Source::Ac, Lever::IgpuMaxMhz, Some("1300")).unwrap();
+        assert_eq!(st.levers.rows[IGPU], ["", "1500", "800", "100", "1300"]);
+        assert_eq!(st.levers.dds[IGPU].selected, 4);
+        assert_eq!(st.levers.dds[IGPU].options[4], "1300 MHz");
+        // And a live cap off the list still shows on the Not set row.
+        st.facts.igpu_mhz = Some(1200);
         rebuild_options(&mut st);
-        assert_eq!(st.ac.rows[IGPU], ["", "1500", "800", "100", "1300"]);
-        assert_eq!(st.ac.dds[IGPU].selected, 4);
+        assert_eq!(st.levers.dds[IGPU].options[0], "Not set — now 1200 MHz");
     }
 
     #[test]
-    fn audio_rows_are_the_three_timeouts_plus_an_off_list_current() {
+    fn audio_rows_are_the_three_timeouts_behind_not_set() {
         let mut st = loaded();
-        assert_eq!(st.battery.rows[AUDIO], ["0", "1", "10"]);
-        assert_eq!(st.battery.dds[AUDIO].options[0], "Never suspend");
-        assert_eq!(st.battery.dds[AUDIO].selected, 2);
-        st.facts.hda_idle_secs = Some(30);
+        assert_eq!(st.levers.rows[AUDIO], ["", "0", "1", "10"]);
+        assert_eq!(st.levers.dds[AUDIO].options[1], "Never suspend");
+        assert_eq!(st.levers.dds[AUDIO].selected, 0);
+        st.facts.plan.put(Mode::PowerSaver, Lever::AudioIdleSecs, Some("30")).unwrap();
         rebuild_options(&mut st);
-        assert_eq!(st.battery.rows[AUDIO], ["0", "1", "10", "30"]);
-        assert_eq!(st.battery.dds[AUDIO].options[3], "After 30 s idle");
+        assert_eq!(st.levers.rows[AUDIO], ["", "0", "1", "10", "30"]);
+        assert_eq!(st.levers.dds[AUDIO].options[4], "After 30 s idle");
     }
 
     #[test]
-    fn aspm_current_is_the_bracketed_policy() {
-        // fetch strips the brackets; the selection must land on the active one.
+    fn aspm_rows_come_from_the_kernels_own_list() {
         let mut st = loaded();
-        st.facts.aspm = "powersave".to_string();
-        rebuild_options(&mut st);
         let i = lever_index(Lever::Aspm);
-        assert_eq!(st.battery.dds[i].selected, 2);
-        assert_eq!(st.battery.dds[i].options[2], "Powersave");
+        st.facts.plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
+        rebuild_options(&mut st);
+        // fetch strips the brackets; the selection lands behind Not set.
+        assert_eq!(st.levers.rows[i], ["", "default", "performance", "powersave"]);
+        assert_eq!(st.levers.dds[i].selected, 3);
+        assert_eq!(st.levers.dds[i].options[3], "Powersave");
     }
 
     #[test]
-    fn gpu_rows_are_default_min_and_an_off_list_current() {
+    fn gpu_rows_are_default_and_min_with_an_off_list_live_value() {
         let mut st = loaded();
         let i = lever_index(Lever::GpuLimitW);
         // Current == default: two rows, no duplicate.
-        assert_eq!(st.battery.rows[i], ["80", "5"]);
-        assert_eq!(st.battery.dds[i].selected, 0);
-        // A current limit that is neither default nor minimum earns its own
-        // row rather than silently selecting the wrong one.
+        assert_eq!(st.levers.rows[i], ["", "80", "5"]);
+        assert_eq!(st.levers.dds[i].selected, 0);
+        // A live limit that is neither default nor minimum is reported on the
+        // Not set row rather than silently selecting the wrong one.
         st.facts.gpu_limit_w = Some(60);
         rebuild_options(&mut st);
-        assert_eq!(st.battery.rows[i], ["80", "5", "60"]);
-        assert_eq!(st.battery.dds[i].selected, 2);
+        assert_eq!(st.levers.rows[i], ["", "80", "5"]);
+        assert_eq!(st.levers.dds[i].selected, 0);
+        assert_eq!(st.levers.dds[i].options[0], "Not set — now 60 W");
+    }
+
+    #[test]
+    fn the_three_sections_paint_and_the_assignment_one_drops_on_a_desktop() {
+        let mut ctx = cce_ui::context::UiContext::new();
+        let mut paint = |st: &mut PowerState, sections: usize| {
+            let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
+            let sec_focused = vec![false; sections];
+            let pc = st.view(10.0, 20.0, 800.0, 600.0, false, &sec_focused, &mut layout, &mut ctx);
+            assert!(!pc.rects.is_empty() || !pc.texts.is_empty());
+        };
+        let mut st = loaded();
+        paint(&mut st, 3);
+        // A desktop has one adapter state and so nothing to assign.
+        st.facts.battery_present = false;
+        rebuild_options(&mut st);
+        paint(&mut st, 2);
+        // And the loading gate paints its one line.
+        let mut empty = PowerState::default();
+        paint(&mut empty, 1);
     }
 
     #[test]
@@ -1037,5 +1248,4 @@ mod tests {
         assert_eq!(live(Lever::Turbo, &f).as_deref(), Some("off"));
         assert_eq!(live(Lever::GpuLimitW, &f).as_deref(), Some("40"));
     }
-
 }
diff --git a/src/power_plan.rs b/src/power_plan.rs
index 27a0482..9114946 100644
--- a/src/power_plan.rs
+++ b/src/power_plan.rs
@@ -1,12 +1,13 @@
-//! The per-power-source plan: which levers to set when the machine is
-//! plugged in and which when it runs on battery.
+//! Power modes and the adapter states they are assigned to: a named set of
+//! levers per mode, and a small table saying which mode runs when the
+//! machine is plugged in and which when it runs on battery.
 //!
 //! Shared between the two sides of the feature, which is the point of the
-//! module: the Power page edits the plan and shows it, and `cce-power-apply`
-//! (this crate's helper binary) applies it as root — from udev when the
-//! Mains supply flips, at boot, and on demand when the page changes a lever
-//! for the source that is active right now. One parser, one apply path, one
-//! value guard, so the two sides cannot drift.
+//! module: the Power page edits the modes and the assignment, and
+//! `cce-power-apply` (this crate's helper binary) applies them as root —
+//! from udev when the Mains supply flips, at boot, and on demand when the
+//! page changes a lever of the mode that is running right now. One parser,
+//! one apply path, one value guard, so the two sides cannot drift.
 //!
 //! The plan lives at [`PLAN_PATH`], root-owned, because the applier runs as
 //! root outside any session: it has no `$HOME` to look in, and a root daemon
@@ -15,18 +16,26 @@
 //! same one-prompt path every lever change in this app already takes.
 //!
 //! ```kdl
-//! ac {
+//! mode "performance" {
 //!     profile "performance"
 //!     turbo "on"
 //! }
-//! battery {
+//! mode "power-saver" {
 //!     profile "low-power"
 //!     igpu_max_mhz 800
 //! }
+//! assign {
+//!     ac "performance"
+//!     battery "power-saver"
+//! }
 //! ```
 //!
-//! A lever absent from a block is left alone when that source becomes
-//! active — "not set" means "don't touch", never "reset to a default".
+//! A lever absent from a mode is left alone when that mode becomes active —
+//! "not set" means "don't touch", never "reset to a default". The older
+//! per-source form of this file (top-level `ac` / `battery` blocks of
+//! levers, before modes existed) still parses: each block becomes the mode
+//! that source is assigned to by default, which is exactly the behavior it
+//! had.
 
 use std::collections::BTreeMap;
 use std::path::{Path, PathBuf};
@@ -38,7 +47,8 @@ pub const PLAN_PATH: &str = "/etc/cce/power.kdl";
 pub const HELPER_SYSTEM_PATH: &str = "/usr/bin/cce-power-apply";
 pub const UDEV_RULE_PATH: &str = "/etc/udev/rules.d/90-cce-power-apply.rules";
 
-/// Defaults to `Ac`: a host with no Mains supply has nothing to unplug.
+/// A power-adapter state. Defaults to `Ac`: a host with no Mains supply has
+/// nothing to unplug.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
 pub enum Source {
     #[default]
@@ -49,7 +59,7 @@ pub enum Source {
 impl Source {
     pub const ALL: [Source; 2] = [Source::Ac, Source::Battery];
 
-    /// The block name in the plan file, and the CLI spelling.
+    /// The key in the `assign` block, and the CLI spelling.
     pub fn key(self) -> &'static str {
         match self {
             Source::Ac => "ac",
@@ -58,11 +68,7 @@ impl Source {
     }
 
     pub fn parse(s: &str) -> Option<Source> {
-        match s {
-            "ac" => Some(Source::Ac),
-            "battery" => Some(Source::Battery),
-            _ => None,
-        }
+        Source::ALL.into_iter().find(|v| v.key() == s)
     }
 
     pub fn label(self) -> &'static str {
@@ -71,11 +77,58 @@ impl Source {
             Source::Battery => "On Battery",
         }
     }
+
+    /// The mode a source runs when the plan says nothing about it. These are
+    /// also what the pre-modes file format migrates onto, so an old plan
+    /// keeps behaving exactly as it did.
+    pub fn default_mode(self) -> Mode {
+        match self {
+            Source::Ac => Mode::Performance,
+            Source::Battery => Mode::PowerSaver,
+        }
+    }
+}
+
+/// A named set of lever values. The set is fixed rather than user-extensible:
+/// the page picks a mode from a dropdown, and there is deliberately no
+/// naming UI to keep a mode's identity stable across the plan file, the
+/// helper's CLI and the assignment table.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
+pub enum Mode {
+    Performance,
+    #[default]
+    Balanced,
+    PowerSaver,
 }
 
-/// The levers that make sense per source. The battery charge limit is
-/// deliberately not one: it is a charging policy, not something to flip on
-/// unplug.
+impl Mode {
+    pub const ALL: [Mode; 3] = [Mode::Performance, Mode::Balanced, Mode::PowerSaver];
+
+    /// The name in the plan file, and the CLI spelling.
+    pub fn key(self) -> &'static str {
+        match self {
+            Mode::Performance => "performance",
+            Mode::Balanced => "balanced",
+            Mode::PowerSaver => "power-saver",
+        }
+    }
+
+    pub fn parse(s: &str) -> Option<Mode> {
+        Mode::ALL.into_iter().find(|m| m.key() == s)
+    }
+
+    pub fn label(self) -> &'static str {
+        match self {
+            Mode::Performance => "Performance",
+            Mode::Balanced => "Balanced",
+            Mode::PowerSaver => "Power Saver",
+        }
+    }
+}
+
+/// The levers that make sense per mode. The battery charge limit is
+/// deliberately not one: it is a charging policy, not something to flip when
+/// the mode changes.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
 pub enum Lever {
     Profile,
@@ -87,7 +140,6 @@ pub enum Lever {
     AudioIdleSecs,
     GpuLimitW,
 }
-
 impl Lever {
     pub const ALL: [Lever; 8] = [
         Lever::Profile,
@@ -156,98 +208,163 @@ pub fn sysfs_token_ok(s: &str) -> bool {
     !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
 }
 
+/// The levers of every mode, plus which mode each adapter state runs.
 #[derive(Debug, Clone, Default, PartialEq, Eq)]
 pub struct PowerPlan {
-    pub ac: BTreeMap<Lever, String>,
-    pub battery: BTreeMap<Lever, String>,
+    modes: BTreeMap<Mode, BTreeMap<Lever, String>>,
+    assign: BTreeMap<Source, Mode>,
 }
 
 impl PowerPlan {
-    pub fn set(&self, source: Source) -> &BTreeMap<Lever, String> {
-        match source {
-            Source::Ac => &self.ac,
-            Source::Battery => &self.battery,
-        }
+    /// One mode's levers. Absent and empty are the same thing to every
+    /// caller, so a mode nothing has been set on reads as an empty set.
+    pub fn levers(&self, mode: Mode) -> impl Iterator<Item = (Lever, &str)> {
+        self.modes
+            .get(&mode)
+            .into_iter()
+            .flat_map(|m| m.iter().map(|(l, v)| (*l, v.as_str())))
     }
 
-    pub fn set_mut(&mut self, source: Source) -> &mut BTreeMap<Lever, String> {
-        match source {
-            Source::Ac => &mut self.ac,
-            Source::Battery => &mut self.battery,
-        }
+    pub fn get(&self, mode: Mode, lever: Lever) -> Option<&str> {
+        self.modes.get(&mode)?.get(&lever).map(String::as_str)
     }
 
-    pub fn get(&self, source: Source, lever: Lever) -> Option<&str> {
-        self.set(source).get(&lever).map(String::as_str)
-    }
-
-    /// Record a value, or clear it with `None`. Rejects a malformed value
-    /// rather than storing it.
-    pub fn put(&mut self, source: Source, lever: Lever, value: Option<&str>) -> Result<(), String> {
+    /// Record a value on a mode, or clear it with `None`. Rejects a
+    /// malformed value rather than storing it.
+    pub fn put(&mut self, mode: Mode, lever: Lever, value: Option<&str>) -> Result<(), String> {
         match value {
             None => {
-                self.set_mut(source).remove(&lever);
+                if let Some(set) = self.modes.get_mut(&mode) {
+                    set.remove(&lever);
+                }
             }
             Some(v) if lever.value_ok(v) => {
-                self.set_mut(source).insert(lever, v.to_string());
+                self.modes.entry(mode).or_default().insert(lever, v.to_string());
             }
             Some(v) => return Err(format!("{:?} is not a valid value for {}", v, lever.key())),
         }
         Ok(())
     }
 
+    /// The mode an adapter state runs; unassigned falls back to the source's
+    /// own default rather than to "do nothing", so a fresh plan still has a
+    /// mode to edit and apply.
+    pub fn assigned(&self, source: Source) -> Mode {
+        self.assign.get(&source).copied().unwrap_or_else(|| source.default_mode())
+    }
+
+    pub fn assign(&mut self, source: Source, mode: Mode) {
+        self.assign.insert(source, mode);
+    }
+
+    /// No lever set on any mode. The assignment alone is not content: it
+    /// changes nothing until some mode has a lever in it.
     pub fn is_empty(&self) -> bool {
-        self.ac.is_empty() && self.battery.is_empty()
+        self.modes.values().all(BTreeMap::is_empty)
     }
 
     pub fn parse(text: &str) -> Result<PowerPlan, String> {
         let doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?;
         let mut plan = PowerPlan::default();
-        for source in Source::ALL {
-            let Some(block) = doc.get(source.key()) else { continue };
-            let Some(children) = block.children() else { continue };
-            for node in children.nodes() {
-                let name = node.name().value();
-                let Some(lever) = Lever::parse(name) else {
-                    return Err(format!("unknown lever {:?} under {}", name, source.key()));
-                };
-                let value = match node.get(0).map(|e| e.value()) {
-                    Some(v) if v.as_string().is_some() => v.as_string().unwrap().to_string(),
-                    Some(v) if v.as_i64().is_some() => v.as_i64().unwrap().to_string(),
-                    _ => return Err(format!("{}.{} needs one string or integer value", source.key(), name)),
-                };
-                plan.put(source, lever, Some(&value))?;
+        for node in doc.nodes() {
+            let name = node.name().value();
+            match name {
+                "mode" => {
+                    let key = node
+                        .get(0)
+                        .and_then(|e| e.value().as_string())
+                        .ok_or_else(|| "mode needs a name, e.g. mode \"balanced\"".to_string())?;
+                    let mode = Mode::parse(key).ok_or_else(|| format!("unknown mode {:?}", key))?;
+                    plan.read_levers(node, mode, key)?;
+                }
+                "assign" => {
+                    let Some(children) = node.children() else { continue };
+                    for child in children.nodes() {
+                        let sname = child.name().value();
+                        let source = Source::parse(sname)
+                            .ok_or_else(|| format!("unknown power source {:?} under assign", sname))?;
+                        let key = child
+                            .get(0)
+                            .and_then(|e| e.value().as_string())
+                            .ok_or_else(|| format!("assign.{} needs a mode name", sname))?;
+                        let mode = Mode::parse(key)
+                            .ok_or_else(|| format!("unknown mode {:?} assigned to {}", key, sname))?;
+                        plan.assign(source, mode);
+                    }
+                }
+                // The pre-modes file: a bare block of levers per adapter
+                // state. Each becomes that state's default mode, which is
+                // what it was already doing.
+                _ => match Source::parse(name) {
+                    Some(source) => {
+                        let mode = source.default_mode();
+                        plan.read_levers(node, mode, name)?;
+                        plan.assign(source, mode);
+                    }
+                    None => return Err(format!("unknown block {:?}", name)),
+                },
             }
         }
         Ok(plan)
     }
 
+    /// The lever children of one block, into `mode`. `what` names the block
+    /// in errors, since the same reader serves both file formats.
+    fn read_levers(&mut self, node: &kdl::KdlNode, mode: Mode, what: &str) -> Result<(), String> {
+        let Some(children) = node.children() else { return Ok(()) };
+        for child in children.nodes() {
+            let name = child.name().value();
+            let Some(lever) = Lever::parse(name) else {
+                return Err(format!("unknown lever {:?} under {}", name, what));
+            };
+            let value = match child.get(0).map(|e| e.value()) {
+                Some(v) if v.as_string().is_some() => v.as_string().unwrap().to_string(),
+                Some(v) if v.as_i64().is_some() => v.as_i64().unwrap().to_string(),
+                _ => return Err(format!("{}.{} needs one string or integer value", what, name)),
+            };
+            self.put(mode, lever, Some(&value))?;
+        }
+        Ok(())
+    }
+
     pub fn to_kdl(&self) -> String {
         let mut doc = kdl::KdlDocument::new();
-        for source in Source::ALL {
-            let mut block = kdl::KdlNode::new(source.key());
+        for mode in Mode::ALL {
+            let mut block = kdl::KdlNode::new("mode");
+            block.push(kdl::KdlEntry::new(mode.key()));
             let children = block.ensure_children();
-            for (lever, value) in self.set(source) {
+            for (lever, value) in self.levers(mode) {
                 let mut node = kdl::KdlNode::new(lever.key());
                 if lever.is_numeric() {
                     // Validated on the way in, so this parse cannot fail;
                     // fall back to the string form rather than panicking.
                     match value.parse::<i64>() {
                         Ok(n) => node.push(kdl::KdlEntry::new(n)),
-                        Err(_) => node.push(kdl::KdlEntry::new(value.as_str())),
+                        Err(_) => node.push(kdl::KdlEntry::new(value)),
                     }
                 } else {
-                    node.push(kdl::KdlEntry::new(value.as_str()));
+                    node.push(kdl::KdlEntry::new(value));
                 }
                 children.nodes_mut().push(node);
             }
             doc.nodes_mut().push(block);
         }
+        let mut assign = kdl::KdlNode::new("assign");
+        let children = assign.ensure_children();
+        for source in Source::ALL {
+            // Resolved, not just what was stored: the file then says out
+            // loud what the applier will do on every adapter state.
+            let mut node = kdl::KdlNode::new(source.key());
+            node.push(kdl::KdlEntry::new(self.assigned(source).key()));
+            children.nodes_mut().push(node);
+        }
+        doc.nodes_mut().push(assign);
         doc.fmt();
         let mut out = String::from(
-            "// Per-power-source settings, edited from the System Interface's Power page\n\
-             // and applied by cce-power-apply (udev, boot, and on each change).\n\
-             // A lever missing from a block is left untouched for that source.\n",
+            "// Power modes and their adapter-state assignment, edited from the\n\
+             // System Interface's Power page and applied by cce-power-apply (udev,\n\
+             // boot, and on each change). A lever missing from a mode is left\n\
+             // untouched when that mode becomes active.\n",
         );
         out.push_str(&doc.to_string());
         out
@@ -437,65 +554,126 @@ pub fn apply_lever(lever: Lever, value: &str) -> Result<(), String> {
     }
 }
 
-/// Apply every lever the plan sets for one source. Failures are per lever —
-/// a missing NVIDIA driver must not stop the CPU profile from landing — and
-/// come back to the caller, which logs them.
+/// Apply every lever one mode sets. Failures are per lever — a missing
+/// NVIDIA driver must not stop the CPU profile from landing — and come back
+/// to the caller, which logs them.
+pub fn apply_mode(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, Result<(), String>)> {
+    plan.levers(mode)
+        .map(|(lever, value)| (lever, apply_lever(lever, value)))
+        .collect::<Vec<_>>()
+}
+
+/// Apply whichever mode is assigned to one adapter state.
 pub fn apply_source(plan: &PowerPlan, source: Source) -> Vec<(Lever, Result<(), String>)> {
-    plan.set(source)
-        .iter()
-        .map(|(lever, value)| (*lever, apply_lever(*lever, value)))
-        .collect()
+    apply_mode(plan, plan.assigned(source))
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    fn levers_of(plan: &PowerPlan, mode: Mode) -> Vec<(Lever, String)> {
+        plan.levers(mode).map(|(l, v)| (l, v.to_string())).collect()
+    }
+
     #[test]
-    fn kdl_round_trip_keeps_both_blocks_and_types() {
+    fn kdl_round_trip_keeps_modes_assignment_and_types() {
         let mut plan = PowerPlan::default();
-        plan.put(Source::Ac, Lever::Profile, Some("performance")).unwrap();
-        plan.put(Source::Ac, Lever::Turbo, Some("on")).unwrap();
-        plan.put(Source::Battery, Lever::Profile, Some("low-power")).unwrap();
-        plan.put(Source::Battery, Lever::IgpuMaxMhz, Some("800")).unwrap();
-        plan.put(Source::Battery, Lever::GpuLimitW, Some("40")).unwrap();
+        plan.put(Mode::Performance, Lever::Profile, Some("performance")).unwrap();
+        plan.put(Mode::Performance, Lever::Turbo, Some("on")).unwrap();
+        plan.put(Mode::PowerSaver, Lever::Profile, Some("low-power")).unwrap();
+        plan.put(Mode::PowerSaver, Lever::IgpuMaxMhz, Some("800")).unwrap();
+        plan.put(Mode::PowerSaver, Lever::GpuLimitW, Some("40")).unwrap();
+        plan.assign(Source::Battery, Mode::Balanced);
         let text = plan.to_kdl();
         // Numbers are written as KDL integers, tokens as strings.
         assert!(text.contains("igpu_max_mhz 800"), "{text}");
         assert!(text.contains("profile \"low-power\""), "{text}");
-        assert_eq!(PowerPlan::parse(&text).unwrap(), plan);
+        assert!(text.contains("mode \"power-saver\""), "{text}");
+        assert!(text.contains("battery \"balanced\""), "{text}");
+        // The file says every assignment out loud, so what comes back is the
+        // same plan with the AC default written down — and writing it again
+        // is a fixed point.
+        let back = PowerPlan::parse(&text).unwrap();
+        assert_eq!(levers_of(&back, Mode::Performance), levers_of(&plan, Mode::Performance));
+        assert_eq!(levers_of(&back, Mode::PowerSaver), levers_of(&plan, Mode::PowerSaver));
+        for source in Source::ALL {
+            assert_eq!(back.assigned(source), plan.assigned(source));
+        }
+        assert_eq!(back.to_kdl(), text);
     }
 
     #[test]
     fn parse_accepts_empty_and_partial_files() {
         assert_eq!(PowerPlan::parse("").unwrap(), PowerPlan::default());
-        let p = PowerPlan::parse("battery {\n  epp \"power\"\n}\n").unwrap();
-        assert!(p.ac.is_empty());
-        assert_eq!(p.get(Source::Battery, Lever::Epp), Some("power"));
-        assert_eq!(p.get(Source::Ac, Lever::Epp), None);
+        let p = PowerPlan::parse("mode \"balanced\" {\n  epp \"power\"\n}\n").unwrap();
+        assert_eq!(p.get(Mode::Balanced, Lever::Epp), Some("power"));
+        assert_eq!(p.get(Mode::Performance, Lever::Epp), None);
+        // Nothing assigned: each adapter state keeps its default mode.
+        assert_eq!(p.assigned(Source::Ac), Mode::Performance);
+        assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
     }
 
     #[test]
-    fn parse_rejects_unknown_levers_and_bad_values() {
-        assert!(PowerPlan::parse("ac {\n  brightness 50\n}\n").is_err());
+    fn the_pre_modes_file_migrates_onto_the_default_modes() {
+        // What /etc/cce/power.kdl looked like before modes existed: a bare
+        // block of levers per adapter state, applied on plug and unplug.
+        let old = "ac {\n  profile \"performance\"\n}\nbattery {\n  profile \"low-power\"\n  igpu_max_mhz 800\n}\n";
+        let p = PowerPlan::parse(old).unwrap();
+        // Each block landed on the mode its source runs, so the same levers
+        // still apply on the same adapter states.
+        assert_eq!(p.assigned(Source::Ac), Mode::Performance);
+        assert_eq!(p.assigned(Source::Battery), Mode::PowerSaver);
+        assert_eq!(p.get(Mode::Performance, Lever::Profile), Some("performance"));
+        assert_eq!(p.get(Mode::PowerSaver, Lever::IgpuMaxMhz), Some("800"));
+        assert!(levers_of(&p, Mode::Balanced).is_empty());
+        // And it rewrites in the new shape.
+        assert!(p.to_kdl().contains("mode \"performance\""));
+        assert_eq!(PowerPlan::parse(&p.to_kdl()).unwrap(), p);
+    }
+
+    #[test]
+    fn parse_rejects_unknown_names_and_bad_values() {
+        assert!(PowerPlan::parse("mode \"balanced\" {\n  brightness 50\n}\n").is_err());
+        assert!(PowerPlan::parse("mode \"turbo-max\" {\n}\n").is_err());
+        assert!(PowerPlan::parse("assign {\n  ac \"turbo-max\"\n}\n").is_err());
+        assert!(PowerPlan::parse("assign {\n  usb \"balanced\"\n}\n").is_err());
+        assert!(PowerPlan::parse("levers {\n  profile \"performance\"\n}\n").is_err());
         // A shell metacharacter never survives into the plan.
-        assert!(PowerPlan::parse("ac {\n  profile \"x;reboot\"\n}\n").is_err());
+        assert!(PowerPlan::parse("mode \"balanced\" {\n  profile \"x;reboot\"\n}\n").is_err());
         // Turbo is on/off only.
-        assert!(PowerPlan::parse("ac {\n  turbo \"yes\"\n}\n").is_err());
+        assert!(PowerPlan::parse("mode \"balanced\" {\n  turbo \"yes\"\n}\n").is_err());
         // A numeric lever given a token.
-        assert!(PowerPlan::parse("ac {\n  gpu_limit_w \"max\"\n}\n").is_err());
+        assert!(PowerPlan::parse("mode \"balanced\" {\n  gpu_limit_w \"max\"\n}\n").is_err());
     }
 
     #[test]
     fn put_none_clears_and_bad_values_are_refused() {
         let mut plan = PowerPlan::default();
-        plan.put(Source::Ac, Lever::Governor, Some("powersave")).unwrap();
-        assert!(plan.put(Source::Ac, Lever::Governor, Some("$(rm)")).is_err());
-        assert_eq!(plan.get(Source::Ac, Lever::Governor), Some("powersave"));
-        plan.put(Source::Ac, Lever::Governor, None).unwrap();
+        plan.put(Mode::Balanced, Lever::Governor, Some("powersave")).unwrap();
+        assert!(plan.put(Mode::Balanced, Lever::Governor, Some("$(rm)")).is_err());
+        assert_eq!(plan.get(Mode::Balanced, Lever::Governor), Some("powersave"));
+        plan.put(Mode::Balanced, Lever::Governor, None).unwrap();
+        assert!(plan.is_empty());
+        // An assignment alone is not content — it changes nothing until a
+        // mode has a lever in it.
+        plan.assign(Source::Ac, Mode::Balanced);
         assert!(plan.is_empty());
     }
 
+    #[test]
+    fn assignment_is_per_source_and_two_states_may_share_a_mode() {
+        let mut plan = PowerPlan::default();
+        plan.put(Mode::Balanced, Lever::Epp, Some("balance_power")).unwrap();
+        plan.assign(Source::Ac, Mode::Balanced);
+        plan.assign(Source::Battery, Mode::Balanced);
+        assert_eq!(plan.assigned(Source::Ac), Mode::Balanced);
+        assert_eq!(plan.assigned(Source::Battery), Mode::Balanced);
+        assert_eq!(PowerPlan::parse(&plan.to_kdl()).unwrap(), plan);
+        // Both states named, so nothing was left to a default.
+        assert!(plan.to_kdl().contains("ac \"balanced\""));
+    }
+
     #[test]
     fn value_guard_shapes() {
         assert!(Lever::Profile.value_ok("balance_power"));
@@ -530,7 +708,11 @@ mod tests {
         for s in Source::ALL {
             assert_eq!(Source::parse(s.key()), Some(s));
         }
+        for m in Mode::ALL {
+            assert_eq!(Mode::parse(m.key()), Some(m));
+        }
         assert_eq!(Lever::parse("brightness"), None);
+        assert_eq!(Mode::parse("ac"), None);
     }
 
     #[test]
@@ -540,11 +722,13 @@ mod tests {
         // Missing file is an empty plan, not an error.
         assert_eq!(PowerPlan::load_from(&path).unwrap(), PowerPlan::default());
         let mut plan = PowerPlan::default();
-        plan.put(Source::Battery, Lever::Aspm, Some("powersave")).unwrap();
+        plan.put(Mode::PowerSaver, Lever::Aspm, Some("powersave")).unwrap();
+        plan.assign(Source::Ac, Mode::Balanced);
+        plan.assign(Source::Battery, Mode::PowerSaver);
         plan.save_to(&path).unwrap();
         assert_eq!(PowerPlan::load_from(&path).unwrap(), plan);
         // Garbage on disk is reported, not silently emptied.
-        std::fs::write(&path, "ac {\n  profile \n").unwrap();
+        std::fs::write(&path, "mode \"balanced\" {\n  profile \n").unwrap();
         assert!(PowerPlan::load_from(&path).is_err());
         let _ = std::fs::remove_dir_all(&dir);
     }