facett-app/src/choices.rs
//! **The CHOICES a scene declares** — every `Tabs` pane, as data.
//!
//! This is the [`facett_core::interface`] shape read off an existing [`Scene`](crate::scene::Scene),
//! with no new machinery and no per-pane code. See
//! `workspace_nordisk/.nornir/app-shape-design-2026-08-02.md` for the argument.
//!
//! # Why the scene already IS the recursive interface
//!
//! `scene` already draws the distinction the app shape needs, and has since it was
//! written:
//!
//! | in the RON | what it means |
//! |---|---|
//! | `layout: Tabs(active: n)` | a **CHOICE** — only the active child renders |
//! | `layout: Split(ratios: …)` | **layout** — every child renders |
//! | `content: Slot("id")` | a **LEAF** — the host draws a widget; recursion stops |
//!
//! So `Tabs` is exactly `options` / `select` / `selected`, and the LIVE selection already
//! lives in [`NavState::active_tab`](crate::scene::NavState) — keyed by pane name, seeded
//! from each `Tabs.active`, mutated by a click, read by the renderer. Nothing had to be
//! invented; it had to be *named*.
//!
//! # What this fixes
//!
//! An app stores a choice and never lets it decide what is drawn under it — so the pane
//! offers controls that do not apply. korp shipped two of these in one binary: a 2D/3D map
//! switch that left the settings beneath it unchanged, and an infra chooser whose only
//! behavioural effect was hiding two tabs.
//!
//! The cause in scene terms is precise: **the recursion stops at `Slot`.** korp's 2D/3D
//! choice lives in imperative code *inside* `Slot("map_canvas")`, where the scene cannot
//! see it, so nothing under it can follow it. Reshaping that slot into a `Tabs` pane with a
//! child per mode makes the choice data, and then the settings under it are simply
//! whichever child is active — there is nothing left to keep in sync.
//!
//! ```ron
//! // before — a choice the scene cannot see
//! Pane(name: "map_canvas", layout: Flow, content: Slot("map_canvas"))
//!
//! // after — the SAME choice, declared
//! Pane(
//! name: "map_canvas", title: "🗺 Canvas",
//! layout: Tabs(active: 0),
//! content: Children([
//! Pane(name: "map_2d", title: "2D", content: Slot("map_2d")),
//! Pane(name: "map_3d", title: "3D", content: Slot("map_3d")),
//! ]),
//! )
//! ```
//!
//! # The guard this makes possible
//!
//! [`distinct_choice_slots`] asserts that the children of every `Tabs` pane render
//! DIFFERENT slots. That is the scene-level form of *"the choice changed nothing"* — the
//! assertion no suite app had on 2026-08-02, and whose absence is why a switch could ship
//! doing nothing at all.
use crate::scene::{Content, Layout, NavState, Pane, Scene};
/// **One declared choice** — a `Tabs` pane and the options it offers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Choice {
/// The `Tabs` pane's own name — the key `NavState::active_tab` is stored under.
pub pane: String,
/// The option pane names, in declared (display) order. This is the ONE list: the
/// strip, the observable state and any test all read it from here.
///
/// korp is the cautionary case — its tab strip, its state oracle and its own test each
/// carried a SEPARATE copy (17, 16 and 9 entries), and the test compared its copy to
/// itself, so it stayed green while the first tab was unreachable.
pub options: Vec<String>,
/// Index into `options` that is active in the scene as authored. The LIVE value is in
/// [`NavState::active_tab`] and may differ — a click moves the live one, never this.
pub authored_active: usize,
}
/// **Every choice a scene declares**, depth-first in declaration order.
///
/// Nested `Tabs` are included, which is the recursion: a tab whose child is itself a
/// `Tabs` pane yields two entries, and `map → 3d → buildings` is three.
pub fn choices(scene: &Scene) -> Vec<Choice> {
let mut out = Vec::new();
walk(&scene.root, &mut out);
out
}
fn walk(pane: &Pane, out: &mut Vec<Choice>) {
if let (Layout::Tabs { active }, Content::Children(kids)) = (&pane.layout, &pane.content) {
out.push(Choice {
pane: pane.name.clone(),
options: kids.iter().map(|k| k.name.clone()).collect(),
authored_active: *active,
});
}
if let Content::Children(kids) = &pane.content {
for k in kids {
walk(k, out);
}
}
}
/// Find a pane by name, anywhere in the tree.
pub fn pane_by_name<'a>(scene: &'a Scene, name: &str) -> Option<&'a Pane> {
fn find<'a>(p: &'a Pane, name: &str) -> Option<&'a Pane> {
if p.name == name {
return Some(p);
}
if let Content::Children(kids) = &p.content {
for k in kids {
if let Some(hit) = find(k, name) {
return Some(hit);
}
}
}
None
}
find(&scene.root, name)
}
/// **The currently-selected child of a `Tabs` pane** — `interface::Interface::selected`,
/// read off the live [`NavState`].
///
/// `None` when `pane` is not a `Tabs` pane (a leaf or a layout), which is the honest answer
/// rather than a guess: a `Split` has no selection because all of its children render.
pub fn selected<'a>(scene: &'a Scene, nav: &NavState, pane: &str) -> Option<&'a Pane> {
let p = pane_by_name(scene, pane)?;
let Layout::Tabs { active } = &p.layout else { return None };
let Content::Children(kids) = &p.content else { return None };
// The LIVE index wins over the authored one — a click moves the live one.
let i = nav.active_tab.get(pane).copied().unwrap_or(*active);
kids.get(i)
}
/// **Choose one of a `Tabs` pane's options** — `interface::Interface::select`.
///
/// Returns `false` for a key that is not on offer, and for a pane that is not a choice.
/// A caller that ignores the result is inviting the silent no-op this whole module exists
/// to remove: a driver reporting success for a click that never landed.
pub fn select(scene: &Scene, nav: &mut NavState, pane: &str, key: &str) -> bool {
let Some(p) = pane_by_name(scene, pane) else { return false };
if !matches!(p.layout, Layout::Tabs { .. }) {
return false;
}
let Content::Children(kids) = &p.content else { return false };
match kids.iter().position(|k| k.name == key) {
Some(i) => {
nav.active_tab.insert(pane.to_string(), i);
true
}
None => false,
}
}
/// **The path to the deepest selected leaf** — what a bug report should name
/// (`map → map_3d`), instead of "somewhere in the map tab".
pub fn selected_path(scene: &Scene, nav: &NavState, from: &str) -> Vec<String> {
let mut path = Vec::new();
let mut here = from.to_string();
// Bounded by the tree's depth; a scene is a tree, so this cannot cycle.
while let Some(child) = selected(scene, nav, &here) {
path.push(child.name.clone());
here = child.name.clone();
}
path
}
/// The slot ids a pane renders, depth-first — its identity for comparison purposes.
///
/// A `Tabs` child that is itself a layout contributes every slot beneath it, so two
/// options that differ only deep down still compare as different.
pub fn slots_of(pane: &Pane) -> Vec<String> {
let mut out = Vec::new();
fn go(p: &Pane, out: &mut Vec<String>) {
match &p.content {
Content::Slot(id) => out.push(id.clone()),
Content::Children(kids) => {
for k in kids {
go(k, out);
}
}
_ => {}
}
}
go(pane, &mut out);
out
}
/// **THE GUARD: a choice must change what is rendered.**
///
/// For every `Tabs` pane, the children must render DIFFERENT slot sets. Two options that
/// draw the same slots are a choice that changes nothing — which is exactly what korp
/// shipped twice, and exactly what no suite app tested.
///
/// Returns `Ok(())` or a message naming the pane and the two colliding options, so the
/// failure says which choice is dead rather than that "a test failed".
///
/// A `Tabs` pane with fewer than two options is also reported: a choice with one option is
/// not a choice, and is either a leftover or a list that failed to populate.
pub fn distinct_choice_slots(scene: &Scene) -> Result<(), String> {
for c in choices(scene) {
let p = pane_by_name(scene, &c.pane).expect("choices() only yields panes from this scene");
let Content::Children(kids) = &p.content else { continue };
if kids.len() < 2 {
return Err(format!(
"Tabs pane {:?} offers {} option(s) — a choice with fewer than two options is \
not a choice",
c.pane,
kids.len()
));
}
for (i, a) in kids.iter().enumerate() {
for b in kids.iter().skip(i + 1) {
let (sa, sb) = (slots_of(a), slots_of(b));
if sa == sb {
return Err(format!(
"Tabs pane {:?}: options {:?} and {:?} render the SAME slots {:?} — the \
choice changes nothing",
c.pane, a.name, b.name, sa
));
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::{Axis, Pane, Scene};
/// A map tab whose canvas is a 2D/3D CHOICE — the reshape this module exists to enable.
fn map_scene() -> Scene {
Scene::new(
"choices-test",
Pane::split(
"map",
Axis::Horizontal,
0.68,
vec![
Pane::tabs(
"map_canvas",
0,
vec![
Pane::slot("map_2d", "map_2d").title("2D"),
Pane::slot("map_3d", "map_3d").title("3D"),
],
)
.title("🗺 Canvas"),
Pane::slot("map_controls", "map_controls").title("Layers"),
],
),
)
}
/// The RON already carries the choice; this reads it back without korp-specific code.
#[test]
fn a_tabs_pane_is_a_choice_and_a_split_is_not() {
let s = map_scene();
let cs = choices(&s);
assert_eq!(cs.len(), 1, "one Tabs pane ⇒ one choice, and the Split is NOT one: {cs:?}");
assert_eq!(cs[0].pane, "map_canvas");
assert_eq!(cs[0].options, vec!["map_2d".to_string(), "map_3d".to_string()]);
}
/// `select` moves the LIVE selection, and `selected` reads it back — the two halves
/// that make the settings under a choice follow the choice.
#[test]
fn selecting_moves_the_live_selection_and_refuses_unknown_keys() {
let s = map_scene();
let mut nav = NavState::new(&s);
assert_eq!(selected(&s, &nav, "map_canvas").map(|p| p.name.as_str()), Some("map_2d"));
assert!(select(&s, &mut nav, "map_canvas", "map_3d"));
assert_eq!(selected(&s, &nav, "map_canvas").map(|p| p.name.as_str()), Some("map_3d"));
// A key that is not on offer must be REFUSED, not silently ignored — a driver
// cannot tell those apart, and would report a click that never happened.
assert!(!select(&s, &mut nav, "map_canvas", "map_4d"));
assert_eq!(
selected(&s, &nav, "map_canvas").map(|p| p.name.as_str()),
Some("map_3d"),
"a refused select must not move the selection"
);
// A Split is not a choice: all of its children render, so there is nothing to
// select. `false`/`None` is the honest answer, not a guess at the first child.
assert!(!select(&s, &mut nav, "map", "map_controls"));
assert!(selected(&s, &nav, "map").is_none());
}
/// The path a bug report should name.
#[test]
fn the_selected_path_names_the_leaf_the_user_is_looking_at() {
let s = map_scene();
let mut nav = NavState::new(&s);
assert!(select(&s, &mut nav, "map_canvas", "map_3d"));
assert_eq!(selected_path(&s, &nav, "map_canvas"), vec!["map_3d".to_string()]);
}
/// **The guard, and the red it must produce.** Two options rendering one slot is a
/// choice that changes nothing — korp's 2D/3D switch in all but name.
#[test]
fn a_choice_whose_options_render_the_same_slots_is_rejected() {
assert!(distinct_choice_slots(&map_scene()).is_ok(), "a real 2D/3D choice must pass");
let dead = Scene::new(
"dead-choice",
Pane::tabs(
"map_canvas",
0,
vec![
Pane::slot("map_2d", "map_canvas").title("2D"),
Pane::slot("map_3d", "map_canvas").title("3D"), // ← the SAME slot
],
),
);
let err = distinct_choice_slots(&dead).expect_err("a dead choice must be rejected");
assert!(err.contains("changes nothing"), "the message must name the defect: {err}");
assert!(err.contains("map_canvas"), "and the pane: {err}");
}
/// A one-option choice is not a choice — either a leftover or a list that failed to
/// populate. korp had exactly that failure with an empty region roster.
#[test]
fn a_choice_with_one_option_is_rejected() {
let lonely = Scene::new(
"lonely",
Pane::tabs("only", 0, vec![Pane::slot("a", "a")]),
);
let err = distinct_choice_slots(&lonely).expect_err("one option is not a choice");
assert!(err.contains("fewer than two"), "{err}");
}
}