nordisk/dwarves
dwarves-gui/src/build_scene.rs
//! **⚒ The build-thing scene** — a build surface that knows no product by name.
//!
//! The pane this replaces was ~3.6k lines of render plus ~1k of test, and it knew
//! products: which cell exports a remote desktop, which one boots a graphical
//! framebuffer, which one is only a scaffold. Every one of those was an `if` over a
//! name, and every new delivery target added another.
//!
//! Here the recipe answers with **data** and the scene renders it. The whole switch is
//! [`PaneKind::for_artifact`] — three arms, no product mentioned:
//!
//! ```text
//! ArtifactKind::Exe -> PaneKind::Exec argv + stdout, run IN A CONTAINER
//! ArtifactKind::Container -> PaneKind::Log start/stop + log + ports
//! ArtifactKind::Iso | ::Vm -> PaneKind::Console run/reboot + serial AND graphical,
//! both shown, never a toggle
//! ```
//!
//! # The flow, one field and one selection at a time
//!
//! ```text
//! 1 type an ARTIFACT NAME the code resolves the recipe itself
//! 2 the home matrix appears every row that recipe declares
//! 3 pick ONE row one at a time
//! 4 pick ONE column the delivery targets that row declares
//! 5 BUILD -> a pane appears BELOW, dynamically
//! ```
//!
//! # What a feature costs, BEFORE the build
//!
//! Between step 4 and step 5 sits the picture that makes step 5 a decision: click a
//! package away and see which feature goes dark, switch a feature off and see which
//! packages vanish. Both directions are the same connection read from opposite ends
//! and share one model ([`Axis`], drawn by one pane), so they cannot drift apart —
//! two tables that must agree is one table too many.
//!
//! Steps 3 and 4 are single-selection *because* step 5 needs an unambiguous subject.
//! Picking a second row therefore clears the column, the outcome and the pane
//! ([`BuildScene::pick_row`]) rather than leaving a pane below that belongs to a row
//! nobody is looking at any more.
//!
//! # Two entry points, ONE state
//!
//! A name is resolved to a recipe; a [`Coordinate`] names the recipe outright, for the
//! case where no artifact exists yet to read a receipt out of. Both land in the same
//! [`BuildScene::menu`], so nothing downstream can tell which door was used — there is
//! one state machine here, not two.
//!
//! # What is deliberately absent
//!
//! No process is started from this file. Every verb is an [`Act`] handed to a
//! [`Forge`], which is the seam a recipe crate plugs into. That is what keeps the
//! product knowledge on the other side of the trait, and it is what
//! `tests/robot_build_scene.rs` measures: the scene's own source, tokenised, may not
//! contain a single product name.
use std::collections::{BTreeMap, BTreeSet};
use dwarves::build_thing::BuildReport;
use dwarves::feature_axis_source::{axis_road, AxisRoad, DerivedAxis};
use egui::{Color32, RichText, Ui};
use facett_app::widgets::CopyableBlock;
use facett_buildthing::galois_pane::GaloisPane;
use facett_core::clip::{put_typed, ClipPayload};
use facett_core::{Facet, Severity};
use nornir_build_thing::contract::{Coordinate, PaneKind, Receipt};
use nornir_build_thing::options::OptionSpec;
use nornir_build_thing::params::{FixtureKind, ParamKind, ParamSpec, FIXTURE_VALUE};
use nornir_build_thing::receipt_proof::ReceiptVerdict;
use nornir_build_thing::tree::{
Edge, SettingsTree, ToggleDecision, ToggleRequest, KERNEL_PATH, PATH_SEP,
};
use nornir_build_thing::variant::ArtifactKind;
use nornir_build_thing::verbs::Action;
use serde_json::{json, Value};
use crate::dag_graph::DagPane;
use crate::settings_tree::{NodeView, Standing, TreeView};
/// The stable dev-id chip this scene shows, and the `facet-<component>-<n>` component
/// its errors carry.
pub const COMPONENT: &str = "buildscene";
/// How often the pane asks for a frame while a build, a boot or a dependency graph is
/// still running somewhere else. See [`Facet::ui`] for what it cost to not have it.
const WORKING_REPAINT: std::time::Duration = std::time::Duration::from_millis(120);
// ── what a recipe answers with ────────────────────────────────────────────────
/// Which door named the recipe. Both resolve into the same [`Menu`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Query {
/// An artifact name, typed. The forge finds the recipe that declares it.
Artifact(String),
/// The recipe itself, addressed. No artifact need exist yet.
At(Coordinate),
}
/// One declared setting of a target, already resolved.
///
/// `fixed` is the difference between a value the operator may change and one the
/// recipe decided. Both are shown: a setting that is missing from the screen is a
/// setting that ends up in the receipt without anyone having seen it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Setting {
/// The receipt key this becomes, e.g. `gpu`.
pub key: String,
/// The resolved value.
pub value: String,
/// The recipe decided it; the scene renders it read-only.
pub fixed: bool,
/// Why it is fixed, when saying so helps ("declared by the recipe").
pub note: Option<String>,
/// **The declared shape**, when this setting came from a cell's
/// `[[build.installer.params]]` declaration rather than a cargo axis.
///
/// `None` for every cargo axis, which carries no such shape. Present, the scene
/// renders the declaration's own label, validates against its kind, and refuses
/// to build without a required value — reading all three out of the SAME
/// `ParamSpec` the CLI door reads, so the pane and `<forge> buildthing
/// --installer --param` cannot drift into asking for different things.
pub declared: Option<DeclaredParam>,
/// **This setting IS an opt-out toggle** the recipe declared
/// (`[[build.installer.options]]`), rather than a value. `None` for every
/// other setting, including the cargo axis's own `open` switches — those
/// flip a feature and nothing else, while an option ALSO decides which
/// parameters below it apply and what gets baked onto the cmdline.
pub declared_option: Option<DeclaredOption>,
/// **This setting is a parameter an opt-out GOVERNS**: it belongs to one
/// state of one toggle, and has no meaning in the other.
///
/// Held here rather than recomputed in the scene because the rule is the
/// recipe's — `OptionSpec::params_when_on` / `params_when_off`, read once
/// where the settings are built. The scene only compares the toggle's
/// CURRENT value against `shown_when`, so the pane, the CLI door and the
/// forge cannot drift into disagreeing about which inputs a build needs.
pub governed: Option<Governed>,
/// **The closed set of values this setting may take.** Empty for every
/// free-text setting and every toggle, which is nearly all of them.
///
/// A setting whose vocabulary is a fixed list is neither a text field (four
/// spellings, three of them typos) nor a checkbox (there are four of them,
/// not two). The scene draws the list and knows nothing about what the words
/// mean; the forge decides what is in it and what each choice COSTS, and
/// carries that cost in [`Choice::note`] so the consequence of a pick is on
/// screen beside the pick rather than derived a second time somewhere else.
pub choices: Vec<Choice>,
}
/// One value a [`Setting`] may take, with what taking it means.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice {
/// The value written into the settings map, verbatim.
pub value: String,
/// What this choice resolves to downstream, in the forge's own words. Shown
/// only for the choice currently taken — four consequences beside four
/// buttons is a wall nobody reads. Empty renders nothing.
pub note: String,
}
impl Choice {
/// A choice with its consequence.
#[must_use]
pub fn new(value: impl Into<String>, note: impl Into<String>) -> Self {
Self {
value: value.into(),
note: note.into(),
}
}
}
/// An opt-out the recipe declares, carried into the scene verbatim.
///
/// The scene knows no capability by name. `dhcp` and `wireguard` appear here
/// because a recipe declares them, exactly as `hub_conf` appears because a
/// recipe declares that — a product that declares no options renders no
/// checkboxes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclaredOption {
/// What the checkbox says — the declaration's own words.
pub label: String,
/// The cargo feature this toggle decides. OFF means the app binary is BUILT
/// without it, so the code is not in the artifact at all; shown in the
/// feature-delta line so an operator can see what the tick actually costs.
pub feature: String,
}
/// Which toggle, and in which of its states, a parameter belongs to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Governed {
/// The option's key.
pub option: String,
/// The state of that option in which this parameter APPLIES. A parameter
/// shown in the other state would be an input nothing on the medium could
/// read.
pub shown_when: bool,
}
/// A parameter the recipe DECLARES, carried into the scene verbatim.
///
/// This is what keeps the pane data-driven. A hardcoded "WireGuard hub key" text
/// box would break [`BuildScene`]'s founding rule — the scene knows no product by
/// name — and would drift the moment a product declared a fifth parameter or
/// renamed one. A product that declares no parameters renders no inputs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclaredParam {
/// What a human is asked for — the declaration's own words.
pub label: String,
/// The value's declared shape, which decides the validation.
pub kind: ParamKind,
/// A build without this value must REFUSE, and say which one is missing.
pub required: bool,
/// **The fixture the recipe declares for this parameter**, by its kind's
/// label (`wg-hub`), when it declares one (`[build.installer.fixtures]`).
/// The scene offers an "använd fixtur" toggle ONLY where this is `Some`;
/// choosing it supplies the same `@fixture` word the CLI supplies, and the
/// forge mints the fixture at build time. The scene knows no fixture by
/// name — it renders whatever label the declaration carries.
pub fixture: Option<String>,
/// The value is a FILE's contents (`kind = "text"` landing as a
/// `staged_file`, e.g. a hub.conf) — rendered as a multi-line editor, since
/// a multi-line file in a single-line field was the 08-16 friction.
pub multiline: bool,
}
impl DeclaredParam {
/// Is `value` acceptable for the declared [`kind`](Self::kind)? The message is
/// the operator's, so it names the shape rather than the rule's implementation.
///
/// Empty is judged by [`required`](Self::required), not here: "you left it
/// blank" and "that is not a key" are different sentences, and merging them
/// tells an operator who typed nothing that their nothing is malformed.
pub fn check(&self, value: &str) -> Result<(), String> {
let v = value.trim();
if v.is_empty() {
return if self.required {
Err(format!("{} is required", self.label))
} else {
Ok(())
};
}
// `@fixture` is legal exactly where the recipe declares a fixture; the
// forge mints the value at build time, so there is no text to shape-check
// here. Anywhere else it is refused BY NAME — the recipe, not the scene,
// decides where a fixture exists.
if v == FIXTURE_VALUE {
return match &self.fixture {
Some(_) => Ok(()),
None => Err(format!(
"{}: {FIXTURE_VALUE} begärd men receptet deklarerar ingen fixtur för den",
self.label
)),
};
}
match self.kind {
ParamKind::Text => Ok(()),
// 32 bytes base64-encoded is 44 chars with exactly one '=' of padding —
// the spelling every WireGuard public key has.
ParamKind::Base64Key => {
let ok = v.len() == 44
&& v.ends_with('=')
&& v[..43]
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/');
ok.then_some(()).ok_or_else(|| {
format!(
"{}: expected a 44-character base64 key, got {} characters",
self.label,
v.len()
)
})
}
ParamKind::Endpoint => v
.parse::<std::net::SocketAddrV4>()
.map(|_| ())
.map_err(|_| format!("{}: expected <a.b.c.d>:<port>", self.label)),
// The contract crate, 0.1.8. Declarable only through an installer
// option's `param_kinds` table (a `[[…params]]` row may not carry the
// spelling — `ParamKind` is a closed enum in the published 0.1.7 loader
// every parity mirror reads through), which is where the DHCP-off static
// address gets its shape.
ParamKind::Ipv4 => v
.parse::<std::net::Ipv4Addr>()
.map(|_| ())
.map_err(|_| format!("{}: förväntade en IPv4-adress <a.b.c.d>", self.label)),
// The SINGULAR of `CidrList`: an interface takes exactly one address, so a
// comma is refused rather than silently taking the head.
ParamKind::Ipv4Cidr => {
if v.contains(',') {
return Err(format!(
"{}: ett gränssnitt tar exakt en adress — ta bort kommat",
self.label
));
}
let Some((addr, prefix)) = v.split_once('/') else {
return Err(format!("{}: {v:?} saknar /<prefix>", self.label));
};
if addr.parse::<std::net::Ipv4Addr>().is_err() {
return Err(format!("{}: {addr:?} är ingen IPv4-adress", self.label));
}
if !prefix.parse::<u8>().is_ok_and(|p| p <= 32) {
return Err(format!(
"{}: prefix {prefix:?} måste vara 0..=32",
self.label
));
}
Ok(())
}
ParamKind::CidrList => {
for part in v.split(',') {
let part = part.trim();
let Some((addr, prefix)) = part.split_once('/') else {
return Err(format!("{}: {part:?} is missing /<prefix>", self.label));
};
if addr.parse::<std::net::Ipv4Addr>().is_err() {
return Err(format!("{}: {addr:?} is not an IPv4 address", self.label));
}
if !prefix.parse::<u8>().is_ok_and(|p| p <= 32) {
return Err(format!("{}: prefix {prefix:?} must be 0..=32", self.label));
}
}
Ok(())
}
}
}
}
impl Setting {
/// A value the operator may change.
#[must_use]
pub fn open(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
fixed: false,
note: None,
declared: None,
declared_option: None,
governed: None,
choices: Vec::new(),
}
}
/// **A parameter the recipe DECLARED** — the site value an operator types.
///
/// Open by construction (a declared parameter the recipe decided would be a
/// fixed setting, not a parameter), and carrying the declaration's own label,
/// kind and required-ness so the scene renders what the recipe said rather than
/// what the UI guessed.
///
/// `fixture` is what the recipe declares for this name in its cell-level
/// `[build.*.fixtures]` table — `None` for most parameters, which renders no
/// fixture affordance at all.
#[must_use]
pub fn param(spec: &ParamSpec, fixture: Option<FixtureKind>) -> Self {
Self {
key: spec.name.clone(),
value: String::new(),
fixed: false,
note: None,
declared: Some(DeclaredParam {
label: if spec.label.trim().is_empty() {
spec.name.clone()
} else {
spec.label.clone()
},
kind: spec.kind,
required: spec.required,
fixture: fixture.map(|f| f.label().to_string()),
multiline: spec.kind == ParamKind::Text && !spec.staged_file.trim().is_empty(),
}),
declared_option: None,
governed: None,
choices: Vec::new(),
}
}
/// **An opt-out the recipe DECLARED** — a checkbox, ticked to whatever the
/// declaration's `default` says.
///
/// An opt-out defaults ON, and that default is the recipe's rather than the
/// scene's: a pane that started every capability unticked would quietly
/// offer to ship an appliance stripped of things nobody asked to remove.
#[must_use]
pub fn option(spec: &OptionSpec) -> Self {
Self {
key: spec.name.clone(),
value: spec.default.to_string(),
fixed: false,
note: None,
declared: None,
declared_option: Some(DeclaredOption {
label: spec.display_label().to_string(),
feature: spec.feature.clone(),
}),
governed: None,
choices: Vec::new(),
}
}
/// A value the recipe decided, with the reason shown beside it.
#[must_use]
pub fn fixed(
key: impl Into<String>,
value: impl Into<String>,
note: impl Into<String>,
) -> Self {
Self {
key: key.into(),
value: value.into(),
fixed: true,
note: Some(note.into()),
declared: None,
declared_option: None,
governed: None,
choices: Vec::new(),
}
}
/// **A value the operator picks from a closed list** — open, like every
/// other control that reaches [`Act::Build`], and carrying the whole
/// vocabulary so the scene never has to invent one.
///
/// This exists because the alternative shapes both lie. A `fixed` setting
/// showing the recipe's answer is a LABEL, and a label is what the ⚒ cell
/// showed about a kernel for as long as the configurator lived in another
/// tab: four true words the operator could not act on. A free text field
/// accepts three spellings that the door then refuses by name.
///
/// # Panics
///
/// Never — but a caller handing an empty list gets a setting that renders as
/// a value nobody can change, which is a `fixed` one written the long way.
#[must_use]
pub fn choice(key: impl Into<String>, value: impl Into<String>, choices: Vec<Choice>) -> Self {
Self {
key: key.into(),
value: value.into(),
fixed: false,
note: None,
declared: None,
declared_option: None,
governed: None,
choices,
}
}
/// Is this setting VISIBLE given the toggles the operator has set?
///
/// A parameter governed by an opt-out disappears in the toggle's other
/// state. Greying it would be wrong for the same reason a greyed column is
/// RIGHT: the column strip is a MAP of every delivery target the repo has,
/// while this block is the argv the build will run with. With DHCP on there
/// is no static address to be had in any state of this build, so a field for
/// one has no business here — and a value left behind in a hidden field
/// would be forwarded to a door that refuses it BY NAME.
#[must_use]
pub fn visible_in(&self, toggles: &BTreeMap<String, bool>) -> bool {
match &self.governed {
None => true,
Some(g) => toggles.get(&g.option).copied().unwrap_or(true) == g.shown_when,
}
}
/// True when this reads as a two-state value, so the scene draws a checkbox
/// instead of a text field. Decided by the VALUE, never by the key's name.
#[must_use]
pub fn is_toggle(&self) -> bool {
matches!(self.value.as_str(), "true" | "false")
}
}
/// One delivery target of one row — a column of the home matrix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// The column label the recipe chose, e.g. `exe`, `iso-installer`.
pub name: String,
/// What comes out. This, and nothing else, decides the pane.
pub kind: ArtifactKind,
/// A recipe may override the pane its kind would get. The override is the
/// exception, never the interface — see [`Target::pane`].
pub pane: Option<PaneKind>,
/// The recipe declares this cell. A column that is not declared is still SHOWN,
/// greyed: a target that silently disappears reads as a target that does not
/// exist, and the matrix is supposed to be the map.
pub declared: bool,
/// The declared settings, already resolved.
pub settings: Vec<Setting>,
/// **The variant this column BUILDS when it is not the row itself** — the declared
/// delivery variant behind an `installer` / `iso` / `container` column
/// (`<x>-installer` on the `<x>-server` row). `None` for the row's own kind.
/// The build graph's node for the column, and the consumption edge from it to the
/// row it packs, are minted from this.
pub variant: Option<String>,
}
impl Target {
/// **The whole switch.** The kind decides; a recipe may override.
#[must_use]
pub fn pane(&self) -> PaneKind {
self.pane
.unwrap_or_else(|| PaneKind::for_artifact(self.kind))
}
}
/// One row of the home matrix: an atom the recipe builds, with its columns.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// The atom's name, as the recipe spells it.
pub name: String,
/// Free-form marks the recipe attaches (role, linkage, whether it is the
/// default). The scene renders them and reads none of them.
pub badges: Vec<String>,
/// The delivery targets, in the recipe's column order.
pub targets: Vec<Target>,
}
/// The home matrix, and the recipe it came out of.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Menu {
/// Which recipe answered.
pub coordinate: Coordinate,
/// How the recipe was found — shown beside the coordinate so the operator can
/// tell a resolved name from a typed address.
pub how: String,
/// Every row that recipe declares.
pub rows: Vec<Row>,
}
/// What a finished build produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
/// The build succeeded.
pub ok: bool,
/// The artifact, as the thing that produced it names it — a path for a file, an
/// image reference for a store.
pub artifact: String,
/// Size, when the artifact is a file that can be stat-ed.
pub size_bytes: Option<u64>,
/// Content hash, when one was taken.
pub sha: Option<String>,
/// The receipt written into the artifact, when the writer ran.
pub receipt: Option<Receipt>,
/// The receipt's claims, measured against the artifact's own symbol table.
///
/// `None` only when there was no artifact to measure. A receipt that could not
/// be read is a [`Proof`] with a verdict saying so, never an absent one — the
/// design's third level is *proven*, and an audit that quietly disappears is
/// the same as one that always passes.
pub proof: Option<Proof>,
/// Wall clock.
pub elapsed_ms: u64,
/// Ports the recipe declared for this cell. The [`PaneKind::Log`] pane offers them.
pub ports: Vec<u16>,
/// One line of extra context the recipe wants shown (a base image, a format).
pub note: Option<String>,
/// **The per-site parameter record the build APPLIED**, one line each, as
/// the forge spells it (`hub_conf ← fixture wg-hub (…)`, `site = stockholm-7`).
/// Empty for a cargo build. A fixture-filled value is never silent here.
pub applied: Vec<String>,
/// **Which cargo features built it** — the build's own record (declared +
/// chosen, and the two flags), as `BuildReport::features_line` spells it.
/// `None` for a cell that is no cargo build.
pub features: Option<String>,
/// **Where this build's provenance record landed** — the one line the report
/// already writes, verbatim.
///
/// ── WHY THIS ROW EXISTS ───────────────────────────────────────────────
///
/// The records pane showed EMPTY four separate times because no producer ever
/// submitted a provenance record. The producer is wired now (MEASURED
/// 2026-08-24: three `build_provenance` parquet files in the served warehouse,
/// against zero before), and the submit receipt was written so that a submit
/// "must never be invisible" — its own words. But this pane never read it:
/// [`Outcome::from_report`] took the report and dropped its warehouse receipt
/// on the floor, and grepping this file and its forge for `warehouse`
/// answered with nothing at all. So the report's own line — documented as
/// "the one line the pane/log shows" — reached the command line and never the
/// pane.
///
/// What that cost the operator: press BYGG on a box with no server address in
/// the environment, get a green build, and the record goes NOWHERE with the
/// screen saying nothing about it. A build filed nowhere rendered identically
/// to one filed — the same blindness as the four empty rounds, seen from the
/// producing end instead of the reading end.
pub warehouse_line: String,
/// A server WAS configured and the record nonetheless did not land — the
/// report's own keyed error. Its own red line, deliberately not folded into
/// [`ok`](Self::ok): a compile that produced a binary is a real compile even
/// when the record missed.
pub warehouse_red: bool,
/// Rows genuinely landed.
///
/// NOT the negation of [`warehouse_red`](Self::warehouse_red), and that is the
/// whole point of carrying both: a build that never tried (no endpoint) is
/// `false` here and `false` there, and it is a THIRD state that must not be
/// coloured like either of the other two.
pub warehouse_landed: bool,
}
impl Outcome {
/// **Fold a finished [`BuildReport`] into the row the pane
/// draws** — the ONE place that decides which file the word "artefakt" names.
///
/// It lives here, pure and beside the struct it builds, because it used to be
/// inline in `BuildForge::settle` and was therefore reachable only by running a
/// real build. A pick nothing can test is a pick that goes wrong quietly, and it
/// did:
///
/// > MEASURED live 2026-08-20 (stinky `kopiera-5`): after KLAR the pane read
/// > `artefakt /boot/vmlinuz-7.0.0-29-generic 16.5 MB`, while the thing that had
/// > just been built was the cell's own `.iso-out/<variant>.iso` at 32 MB.
///
/// An iso-kind build lists its provenance INPUTS first — kernel, chained
/// payload, then the mastered image — a splice order that is load-bearing for
/// the attestation and is not reordered here. `artifacts.first()` therefore
/// names an input. [`BuildReport::medium`] is the
/// kind-aware pick, and it is the same one the headline and the ▶ boot read, so
/// the row, the CLI line and QEMU's `-cdrom` cannot disagree about what was
/// built.
///
/// `size_bytes` comes off the SAME entry as the path. Taking the path from one
/// artifact and the size from another is exactly how the live report managed to
/// show a 16.5 MB figure beside a 32 MB file.
#[must_use]
pub fn from_report(
report: &BuildReport,
ports: Vec<u16>,
receipt: Option<Receipt>,
proof: Option<Proof>,
) -> Self {
let medium = report.medium().cloned();
Self {
ok: report.is_green(),
// A kind whose product is not a file has no medium on disk; then the
// produced reference (an image ref) IS the artifact. Never a plausible
// path invented to fill the row.
artifact: medium
.as_ref()
.map(|(p, _)| p.display().to_string())
.or_else(|| report.produced_ref.clone())
.unwrap_or_default(),
size_bytes: medium.map(|(_, n)| n),
sha: report
.produced_ref
.as_ref()
.and_then(|r| r.split_once("sha256:").map(|(_, h)| h.to_string())),
receipt,
proof,
elapsed_ms: report.elapsed_ms,
ports,
note: report.setup_error.clone(),
applied: report.applied_params.clone(),
features: report.features_line(),
// Read off the SAME writer the CLI reads, never re-worded here: three
// states with three sentences, and this pane only chooses the colour.
warehouse_line: report.warehouse_line(),
warehouse_red: report.warehouse_error_code().is_some(),
warehouse_landed: report.warehouse.as_ref().is_some_and(|r| r.landed()),
}
}
}
/// **A node's note, whole.** Indented under its row and WRAPPED at the panel's width —
/// never one clipped line of the row's horizontal layout.
pub(crate) fn node_note(ui: &mut Ui, text: &str, depth: usize) {
ui.horizontal_top(|ui| {
ui.add_space(depth as f32 * 18.0 + 18.0);
ui.add(egui::Label::new(RichText::new(text).small().color(AMBER)).wrap());
});
}
/// **The third level.** What the artifact's own symbol table says about the claims
/// its receipt makes.
///
/// ```text
/// declared the operator picked the pattern
/// validated the recipe said the pattern is legal
/// proven the symbol table of the built binary shows it came out that way
/// ```
///
/// Only [`ReceiptVerdict::Proven`] passes. `NotMeasured` — a stripped binary, an
/// artifact that is not an object file at all — is its own outcome and is shown as
/// one, because an unchecked claim is not a verified claim and folding the two
/// together is what makes a guard green for ever.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Proof {
/// The verdict, as the audit reached it.
pub verdict: ReceiptVerdict,
/// The audit's own sentences: a headline, then one per measured claim.
pub lines: Vec<String>,
}
impl Proof {
/// Whether the artifact backs up its own receipt.
#[must_use]
pub fn holds(&self) -> bool {
self.verdict.holds()
}
/// The one word the pane shows beside `bevis`.
#[must_use]
pub fn label(&self) -> &'static str {
match self.verdict {
ReceiptVerdict::Proven => "BEVISAT",
ReceiptVerdict::Disproven => "MOTSAGT",
ReceiptVerdict::NoReceipt => "INGET KVITTO",
ReceiptVerdict::MalformedReceipt => "TRASIGT KVITTO",
ReceiptVerdict::NothingProvable => "INGET ATT BEVISA",
ReceiptVerdict::NotMeasured => "EJ MÄTT",
}
}
}
/// A frame of a graphical console. Never synthesised: a console with no frame yet
/// says so.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Screen {
/// Width in pixels.
pub width: usize,
/// Height in pixels.
pub height: usize,
/// Row-major RGBA8, `width * height * 4` bytes.
pub rgba: Vec<u8>,
}
/// Where the forge is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase {
/// Nothing has been asked for.
Idle,
/// A build is in flight.
Building,
/// A build finished; see [`Outcome::ok`].
Done,
/// A build could not be started or did not finish. Carries the reason verbatim.
Failed(String),
}
impl Phase {
/// The label the result header shows.
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Phase::Idle => "VILAR",
Phase::Building => "BYGGER",
Phase::Done => "KLAR",
Phase::Failed(_) => "FEL",
}
}
}
/// Everything the scene draws about work in flight or finished. Polled once a frame.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Report {
/// The build's phase.
pub phase: Option<Phase>,
/// Wall clock of the build.
pub elapsed_ms: u64,
/// The finished build, if there is one.
pub outcome: Option<Outcome>,
/// The pane's subject is up: a process, a container, a guest.
pub live: bool,
/// stdout / log / serial, cumulative.
pub lines: Vec<String>,
/// The graphical console's latest frame, when it has one.
pub screen: Option<Screen>,
/// An honest reason the pane cannot do what it offers — a missing runtime, a
/// feature not compiled in. Shown instead of a fabricated result.
pub note: Option<String>,
/// **The matrix, re-answered.** A forge whose answer to the standing query changed
/// under the scene — the recipe crate's wasm landed and the CODE now answers where
/// the table did — hands the new matrix up here, once. The scene swaps it in and
/// keeps the operator's row, column and edits by name. `None` on every other frame.
pub menu: Option<Menu>,
}
/// **What the dependency picture is a picture OF.**
///
/// The row alone is not enough, and that was the bug. MEASURED 2026-08-22: the
/// picture was derived from the picked ROW's own cargo features, so an
/// INSTALLER row — which declares none, because the thing it builds is the
/// appliance it packs — resolved to `Axis::Failed("… bygger utan features …")`
/// and drew nothing, while the app row beside it drew fine. Moving between the
/// two is what "the picture sometimes flickers in" actually was.
///
/// It is also not enough to be honest: a picture of the DECLARED feature set on
/// a build whose opt-outs subtract from it shows parts the artifact will not
/// contain. So the pick carries all three, and the scene re-asks whenever any
/// of them changes.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AxisPick {
/// The picked row.
pub row: String,
/// The picked column, when one is picked. An installer/iso column makes the
/// picture about the variant that column PACKS.
pub target: Option<String>,
/// The opt-outs as they stand. A feature switched off is subtracted before
/// the picture is derived.
pub toggles: BTreeMap<String, bool>,
/// **The crates whose minimal-cause hunt the operator has actually asked for.**
///
/// Part of the PICK, not a side channel, and that is the whole mechanism: the
/// scene re-asks whenever the pick changes, so clicking "hunt this one" is the
/// same event as picking a different row. The forge spends its budget on exactly
/// this set — see the axis source's own `AxisRequest::focus`.
///
/// Empty is the normal state of a fresh pick: no hunt, and the press lands.
pub focus: BTreeSet<String>,
}
/// What the scene asks the forge to do. The scene runs nothing itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Act {
/// Step 5. The row and column are named, never indexed: an index is meaningless
/// to whoever implements this.
Build {
/// The picked row's name.
row: String,
/// The picked column's name.
target: String,
/// The settings as they stand on screen, resolved.
settings: BTreeMap<String, String>,
},
/// [`PaneKind::Exec`]: run the artifact with this argv.
Run(String),
/// [`PaneKind::Log`] / [`PaneKind::Console`]: bring the subject up.
Start,
/// Take it down.
Stop,
/// [`PaneKind::Console`]: power-cycle it.
Reboot,
}
/// **What the forge has for a row's dependency axis** — the picture of what a
/// feature costs, asked for BEFORE anything is built.
///
/// Four states and no fifth, because the three ways of having no picture are three
/// different sentences and an empty graph renders identically to all of them:
/// "this row has no axis", "it is being worked out" and "it could not be derived,
/// here is why" are what an operator has to be able to tell apart.
///
/// [`Working`](Self::Working) is the only non-terminal one. Deriving an axis means
/// resolving a whole lockfile universe and then hunting minimal causes over it —
/// measured in tens of seconds on this constellation — so the forge answers with it
/// immediately and the scene keeps asking. Doing that work on the frame that asked
/// for it would be a frozen window, not a picture.
#[derive(Debug, Clone)]
pub enum Axis {
/// This forge derives no axis. Not a failure: a forge that has no manifests to
/// read has nothing to say, and saying nothing is the honest answer.
Absent,
/// Being derived. Terminal only when it is not.
Working,
/// Ready — the picture, PLUS the removable crates whose minimal-cause hunt was
/// not paid for. The second half is not a detail: an unhunted crate is absent
/// from the graph, and absent reads as "not in the build" unless something says
/// otherwise.
Ready(Box<DerivedAxis>),
/// It could not be derived, and this is the reason, verbatim.
Failed(String),
}
/// **An extra surface the FORGE draws inside the pane** — the seam that lets a
/// product-shaped configurator live in ⚒ without ⚒ learning what it configures.
///
/// The scene owns the frame (a named, foldable, resizable section under the
/// settings block) and the forge owns everything inside it. That division is the
/// same one the rest of this file keeps: the scene knows there is a leaf, its
/// title, and that its state folds into `state_json`; it does not know that any
/// particular leaf exists.
///
/// Three answers and no fourth, for the reason [`Axis`] has four: an absent leaf,
/// a leaf that cannot be shown, and a leaf. **A refusal is not an empty frame** —
/// "this selection has no such configurator" and "it has one and here is why it
/// is not up" are different sentences, and a blank pane says neither.
pub enum Leaf<'a> {
/// This selection has no such surface at all. Nothing is drawn.
None,
/// It would have one, and this is why it is not there — verbatim, in the
/// forge's own words.
Refused(String),
/// The surface. Drawn by the scene, owned by the forge.
Pane(&'a mut dyn Facet),
}
// There used to be a `BROWSE_NONE_HINT` const here — the kernel leaf's "pick a recipe"
// sentence for the nothing-resolved state. It is GONE because the state it decorated
// is gone from the screen: the kernel leaf does not EXIST until the resolved recipe
// declares a `[[build.kernel]]` ([`Forge::kernel_declared`] → `LeafPanel::leaf_absent`
// → the scene's runtime presence), so there is no region for the sentence to sit in.
// Dead text left standing would be read as the running layout — the exact failure
// `build_thing_scene.rs`'s docs record about `body()`'s callerless years.
/// **The seam a recipe plugs into.** Everything product-shaped is on the far side.
pub trait Forge {
/// Steps 1–2: a name, or a coordinate, becomes a home matrix.
///
/// # Errors
///
/// The reason, in words, when nothing answers to the query. The scene shows it
/// verbatim — a resolver that fails silently is indistinguishable from an empty
/// product.
fn resolve(&mut self, query: &Query) -> Result<Menu, String>;
/// **The picked selection's feature↔dependency axis**, for the picture drawn ABOVE
/// the build button.
///
/// Per ROW, because a row is an assembly: what a feature costs depends on which
/// assembly you are carving, and one axis for the whole repo would answer a
/// question nobody asked.
///
/// Called every frame until it answers something terminal, so an implementation
/// must be cheap on the repeat calls — start the work on the first one, hand
/// back [`Axis::Working`], and answer from the finished result afterwards.
///
/// No default implementation on purpose. A defaulted `Axis::Absent` would let a
/// forge that MEANT to derive one silently stop, and the scene cannot tell that
/// apart from a forge that never had one.
fn axis(&mut self, pick: &AxisPick) -> Axis;
/// **The forge's own configurator for this selection**, drawn between the
/// settings and the button — the place where the thing being chosen is
/// chosen, rather than eleven tabs away.
///
/// Called once a frame with the settings AS THEY STAND, which is what makes
/// the leaf and the settings block one control instead of two: a forge that
/// mirrors a settings value into its leaf here cannot show a different pick
/// than the one [`Act::Build`] will carry.
///
/// Defaulted to [`Leaf::None`] — unlike [`axis`](Forge::axis), because
/// "this forge has no extra surface" is the honest answer for a forge that
/// never had one, and there is no second story a default could hide. A forge
/// that MEANT to draw one and stopped shows nothing, exactly as it should.
#[allow(unused_variables)]
fn leaf(&mut self, row: &str, target: &str, settings: &BTreeMap<String, String>) -> Leaf<'_> {
Leaf::None
}
/// **The same configurator with NOTHING picked** — the standing question, not the
/// selection's.
///
/// [`leaf`](Forge::leaf) can only answer about a cell, so for as long as this seam
/// was the only one the ⚒ configurator vanished the moment the selection cleared,
/// and *"what is in a `slim` kernel"* had no home but a separate ⚙ tab. That is a
/// real question — it is the one an operator has BEFORE they know which row they
/// want — and one tab per un-selected question is not a design.
///
/// The contract that makes hosting the same facet twice safe is that it is the SAME
/// INSTANCE: an implementation must hand back the very pane [`leaf`](Forge::leaf)
/// hands back, with its own controls turned ON because no host control is pointing
/// at it this frame. Two instances would be two presets, and the operator would pick
/// in one and build the other.
///
/// Defaulted to [`Leaf::None`] for the same reason [`leaf`](Forge::leaf) is: "this
/// forge has no standing surface" is honest, and there is no second story it hides.
fn browse(&mut self) -> Leaf<'_> {
Leaf::None
}
/// **Does the currently resolved recipe have a KERNEL ANSWER — a
/// `[[build.kernel]]` of its own, or a donor's kernel bound by
/// `[build.installer.kernel_donor]`?**
///
/// This is the kernel leaf's PRESENCE, not its content: the ⚒ tree only grows a
/// kernel region at all when this answers `true` (`LeafPanel::leaf_absent` → the
/// scene's runtime presence). [`leaf`](Forge::leaf) and [`browse`](Forge::browse)
/// answer *what the region shows* — the configurator for an own-kernel recipe, the
/// donor NAMED (and where the choice is made) for a donor-consuming installer;
/// a question about what a region should show is meaningless for a region that
/// must not exist, which is why this is its own method and not inferred from those
/// answers (calling `browse` to peek has side effects — it flips the
/// configurator's chrome and builds the pane lazily).
///
/// `false` with nothing resolved: no recipe is no declaration — the strict rule is
/// *"the kernel leaf must not exist until the resolved recipe declares a
/// `[[build.kernel]]`"* (owner, 2026-08-26, on seeing the pane frame before he
/// had typed anything). `false` for a resolved recipe with neither declaration: a
/// plain library gets a four-leaf ⚒. `true` for the donor road: the installer's
/// medium BOOTS the donor repo's kernel, and a hidden leaf would call that "no
/// kernel", which is false — the donor-naming surface IS that recipe's kernel
/// answer. Defaulted `false` —
/// "this forge builds no kernel" is the honest answer for a forge that never had
/// one, the same reasoning as `leaf`/`browse`'s defaults.
fn kernel_declared(&self) -> bool {
false
}
/// **The settings TREE for this selection** — the model crate's
/// [`SettingsTree`] (one recursive node per variant:
/// feature ▸ setting ▸ val, paths as ids, the kernel donor as an edge), with the
/// values AS THEY STAND folded in where the shape depends on them (the preset
/// decides which kernel symbols exist).
///
/// With a row and a column picked, the picked variant's tree. With a recipe resolved
/// and nothing picked, the STANDING question's: the variant that carries the `kärna`
/// feature — the recipe's own kernel, or the installer with the donor edge — or an
/// empty tree when none does. `None` only with no recipe resolved.
///
/// Defaulted to `None`: "this forge declares no settings" is the honest answer for a
/// forge that never had any. Stage 2 of the model answers this from the loaded
/// build thing's `options(path)` over wasm; today the forge derives it from the
/// declaration through the model crate's `SettingsTree::of_set`.
#[allow(unused_variables)]
fn tree(
&mut self,
row: Option<&str>,
target: Option<&str>,
settings: &BTreeMap<String, String>,
) -> Option<SettingsTree> {
None
}
/// Step 5, and every verb the pane below offers.
/// **The judge for a feature click** — the model crate's `try_toggle` (cascade and contract
/// lock included), answered by the loaded build thing over wasm when the code road
/// is open, by the derived tree otherwise. Defaulted to a refusal that says so: a
/// forge that never had a judge must not silently accept a click.
fn try_toggle(&mut self, req: &ToggleRequest) -> ToggleDecision {
ToggleDecision {
path: req.path.clone(),
on: req.on,
verdict: crate::actions::refuse("den här smedjan har ingen domare för klick"),
cascade: Vec::new(),
refuses: Vec::new(),
activates: Vec::new(),
}
}
/// **The verbs a typ bears** — the model crate's `actions(kind)`: the loaded build thing's
/// answer over wasm when the code road is open, the crate's own table
/// (the model crate's table) otherwise. Data either way; the pane
/// branches on nothing.
fn actions(&mut self, kind: ArtifactKind) -> Vec<Action> {
crate::actions::verbs_for(kind)
}
/// **The product word the CLI takes** for the resolved recipe — `<forge> buildthing
/// <product>`, the same spelling line 119 uses — so the line the node menu shows is
/// the line the prompt accepts.
/// `None` when the forge cannot name it; the menu then spells `<produkt>`, never a
/// guess.
fn product(&self) -> Option<String> {
None
}
/// **The `--param` line a variant's DECLARED parameters come to**, given the site
/// values as they stand — `["admin_key=ssh-ed25519 AAAA…"]`, exactly as
/// the `buildthing --param` flag spells them.
///
/// Its own method rather than something the scene derives, because only the forge
/// holds the cell that declares which settings ARE parameters and which toggle
/// governs each one — an unticked WireGuard must drop a typed `hub_conf` rather than
/// forward it to a door that would refuse it by name. It is asked twice on purpose
/// and by ONE writer: once by the plan the click sends, once by the CLI line printed
/// beside the button. A printed command missing the operator's own typed values is
/// refused at the prompt for a required parameter that was in fact supplied.
///
/// Defaulted to empty — "this forge declares no parameters" is honest for a forge
/// that never had any, the same reasoning as `leaf`/`browse`.
#[allow(unused_variables)]
fn declared_params(
&mut self,
variant: &str,
settings: &BTreeMap<String, String>,
) -> Vec<String> {
Vec::new()
}
/// **`BYGG <kant>` — the chained build of an edge's build thing** (wave 3, gap #4):
/// the donor as a step with the scoped `--opt` line, then the consumer, in DAG
/// order, run by runa in the toolchain container. `Ok(())` when the run started;
/// `Err` with the NAMED reason when it did not — a forge without the runner says
/// [`crate::build_steps::missing_runner`], never a silent no-op. Defaulted to that
/// refusal: "this forge has no chain runner" is the honest answer for one that
/// never had it, and the step console shows the refusal on every step.
///
/// # Errors
/// The reason, verbatim, when nothing started.
///
/// Answers the plan it ACTUALLY started, which is rarely the one it was handed:
/// the click can only ever name two steps (this node's edge and the variant),
/// while the chain is a DAG and the donor's donor is a step too. The console
/// restages on the answer, so what is drawn is what runa was given.
fn build_edge(
&mut self,
plan: &crate::build_steps::StepPlan,
) -> Result<crate::build_steps::StepPlan, String> {
let _ = plan;
Err(crate::build_steps::missing_runner())
}
/// **The chain this edge WOULD run — planned, nothing spawned.** The dry pass,
/// the pane's `--list --build`: the donor checkouts resolved and type-checked,
/// the opts routed, the order derived, and for a kernel the pin asked what it
/// would do. Reads; never builds.
///
/// Its own method rather than a flag on [`build_edge`](Forge::build_edge)
/// because the difference is not a shade of the same act: a robot that wants to
/// compare the two doors' plans must be able to ask WITHOUT starting a kernel
/// build, and a parameter that decides whether minutes of compute happen is a
/// parameter somebody will pass wrong.
///
/// # Errors
/// The named refusal — the same one `build_edge` would give, from the same
/// planner, before anything spawns.
fn plan_edge(
&mut self,
plan: &crate::build_steps::StepPlan,
) -> Result<crate::build_steps::StepPlan, String> {
let _ = plan;
Err(crate::build_steps::missing_runner())
}
/// **The chained build's steps as they stand** — one report per step the runner
/// has touched, polled every frame while a `build_edge` runs. `None` when nothing
/// is running (the default, and the answer of a forge without the runner).
fn steps(&mut self) -> Option<Vec<crate::build_steps::StepReport>> {
None
}
fn act(&mut self, act: Act);
/// Called once a frame. Cheap; the forge caches.
fn poll(&mut self) -> Report;
}
// ── the scene ─────────────────────────────────────────────────────────────────
/// **The consoles a [`PaneKind::Console`] pane shows — all of them, at once.**
///
/// Not a selector. During an install the serial says what is HAPPENING and the graphical
/// says what the USER SEES, and a toggle forces a choice between them in the one
/// situation where both are needed. Named here so a robot can assert that both are up
/// rather than infer it from two separate widgets.
pub const CONSOLES: [&str; 2] = ["serial", "graphical"];
/// The ⚒ build-thing scene.
pub struct BuildScene {
/// The far side of the trait: where every product name lives.
forge: Box<dyn Forge>,
/// Step 1's field.
artifact: String,
/// The other door: a coordinate, typed.
coordinate: String,
/// The resolved home matrix. `None` until a query answers.
menu: Option<Menu>,
/// The last resolve failure, verbatim.
error: Option<String>,
/// The picked row. One at a time.
row: Option<usize>,
/// The picked column. One at a time, and cleared whenever the row changes.
target: Option<usize>,
/// Operator edits to the picked target's open settings, keyed by setting key.
chosen: BTreeMap<String, String>,
/// The [`PaneKind::Exec`] argv field.
argv: String,
/// The forge's latest answer.
report: Report,
/// The uploaded graphical frame. Not in `state_json` — a texture handle does not
/// serialise, and the robot asserts the frame's dimensions instead.
texture: Option<egui::TextureHandle>,
/// **The two directions, one model.** Click a feature off and watch the packages
/// it was holding up leave; click a package and watch the features that pull it
/// go. Both are the same connection read from opposite ends, which is why there
/// is one pane here and not two — a second table for the reverse direction is a
/// second thing that can disagree with the first.
galois: Option<GaloisPane>,
/// The row [`Self::galois`] (or [`Self::axis_note`]) belongs to. While this is
/// not the picked row, the scene keeps asking the forge; once it matches, the
/// answer is terminal and the asking stops.
axis_row: Option<AxisPick>,
/// Why there is no picture, verbatim. An absent graph must never be a blank
/// space: "still working" and "could not be derived" are different sentences.
axis_note: Option<String>,
/// **Which road the picture on screen came down**, classified the moment the
/// axis lands.
///
/// The graph has always carried its own provenance — but as ONE long sentence
/// printed `.weak().small()` at the bottom of the pane, which is the same as not
/// carrying it. A picture derived from manifests renders identically to one read
/// out of the warehouse, so a pane that does not say which is a picture whose age
/// nobody knows. Three states, three colours, above the graph, in the pane's own
/// words.
axis_road: Option<AxisRoad>,
/// **Removable crates the graph is NOT showing, because nobody paid for their
/// minimal-cause hunt yet.**
///
/// MEASURED: the hunt is 10-16 s PER CRATE, and a real pick has 98 removable
/// crates. Doing them all up front is 17-27 minutes, so the pane asks for none of
/// them and buys one per click instead.
///
/// They have to be listed. An unhunted crate is not in `FeatureAxis::analyses`
/// at all, and the pane's `in_build` reads an absent crate as `false` — so
/// leaving these silent would draw "we have not looked" as "not in the build".
axis_unanalysed: Vec<String>,
/// The crates the operator has clicked "hunt" on, carried into the next
/// [`AxisPick`]. Survives a re-derive of the SAME row (that is the point) and is
/// dropped with the rest of the axis when the row changes.
axis_focus: BTreeSet<String>,
/// **The forge's leaf, as it last drew** — its title and its own observable
/// state, cached on the frame that drew it so [`Facet::state_json`] (which is
/// `&self`) can fold it in. A leaf whose state is invisible to a driver is a
/// leaf no test can prove is on screen.
leaf: Value,
/// **The settings tree as it last drew** — cached on the frame that drew it, like
/// [`Self::leaf`], so `state_json` reports every node with its path and its flag.
/// `None` with no recipe resolved.
tree: Option<TreeView>,
/// **The build graph pane** — the one DAG renderer, holding pan, zoom, folds and
/// the selection across spec rebuilds.
graph: DagPane,
/// Node ids the operator has UNFOLDED — kept across rebuilds; everything else with
/// children starts folded.
graph_open: BTreeSet<String>,
/// The graph's selected node id as this scene last saw it, so a canvas click is
/// detected as a change rather than re-applied every frame.
graph_selected: Option<String>,
/// What the current spec is a picture OF; a different key rebuilds the spec.
graph_key: String,
/// The menu's subject within the picked variant's tree: a node path, or `None` for
/// the variant itself.
menu_node: Option<String>,
/// The judge's last refusal (a kernel symbol that would break the boot contract),
/// verbatim, or a note about a node that is another recipe's.
menu_note: Option<String>,
/// **The RUN receipt** — the second receipt (law E): the verb that ran, where, and
/// the outcome the console measured. `None` until a verb fires.
run_receipt: Option<crate::actions::RunReceipt>,
/// The `run.where` val as picked: `container` (the default) or `vm`.
run_where: String,
/// Why a verb did not run, verbatim (a program in a vm is not wired yet).
run_note: Option<String>,
/// The verbs the forge last answered for the built typ, so `state_json` (`&self`)
/// reports what the menu drew without asking the forge again.
last_verbs: Vec<crate::actions::Verb>,
/// **The step console** — the edge DAG the last `BYGG <kant>` planned, its steps'
/// states, the clock, the receipt beside the build's and the RUN's.
steps: crate::build_steps::StepConsole,
/// **The BYGG verbs the menu drew this frame** — one per node with a consumption
/// edge: its label, path, coordinate, typ and the CLI line it equals. Cached on
/// draw so `state_json` (`&self`) reports what was on screen.
build_verbs: Vec<Value>,
/// Why a variant of a kind that COULD consume a kernel (an iso, an installer) offers
/// no BYGG: it declares no edge — and whose decision that is.
no_edge_why: Option<String>,
/// **Drawn as the ⚒ scene's five LEAVES rather than as one flow.** Set by
/// [`Self::hosted`] at mount. It changes exactly one thing: a region that has to
/// wear its own `CollapsingHeader` when five regions share one `Slot` must NOT wear
/// one when the scene has already given it a chromed, foldable pane with a title.
/// Two nodes reading "Kärna" one inside the other is the duplicate-name defect this
/// tab has now paid for twice.
hosted: bool,
/// The egui pass [`Self::begin_pass`] last ran on — so the once-a-frame prologue is
/// once a frame whether one surface draws this pane or five.
pass: Option<u64>,
}
impl BuildScene {
/// A scene over a forge.
#[must_use]
pub fn new(forge: Box<dyn Forge>) -> Self {
Self {
forge,
artifact: String::new(),
coordinate: String::new(),
menu: None,
error: None,
row: None,
target: None,
chosen: BTreeMap::new(),
argv: String::new(),
report: Report::default(),
texture: None,
galois: None,
axis_row: None,
axis_note: None,
axis_road: None,
axis_unanalysed: Vec::new(),
axis_focus: BTreeSet::new(),
leaf: Value::Null,
tree: None,
graph: DagPane::new("byggraf"),
graph_open: BTreeSet::new(),
graph_selected: None,
graph_key: String::new(),
menu_node: None,
menu_note: None,
run_receipt: None,
run_where: "container".to_string(),
run_note: None,
last_verbs: Vec::new(),
steps: crate::build_steps::StepConsole::new(),
build_verbs: Vec::new(),
no_edge_why: None,
hosted: false,
pass: None,
}
}
/// **Mount this scene as the ⚒ tab's five LEAVES** rather than as one flow.
///
/// Consuming, so the shell states the shape once where it mounts the tab and the
/// pane cannot be half-hosted. Everything else is identical: the same forge, the
/// same state, and the same `Facet::ui` when something boxes it whole.
#[must_use]
pub fn hosted(mut self) -> Self {
self.hosted = true;
self
}
/// Whether this instance is drawn as scene leaves (the host owns the chrome).
#[must_use]
pub fn is_hosted(&self) -> bool {
self.hosted
}
// ── steps 1 and 2 ─────────────────────────────────────────────────────────
/// Type into the `artefakt` field headlessly — the bench's and a robot's twin of the
/// keystrokes. Pair with [`Self::resolve_artifact`].
pub fn set_artifact(&mut self, name: &str) {
self.artifact = name.to_string();
}
/// Step 1: resolve the typed artifact name.
pub fn resolve_artifact(&mut self) {
let name = self.artifact.trim().to_string();
if name.is_empty() {
self.error = Some("skriv ett artefaktnamn först".to_string());
return;
}
let resolved = self.forge.resolve(&Query::Artifact(name.clone()));
self.apply(resolved, Some(&name));
}
/// The other door: resolve the typed coordinate. Same state, same matrix.
pub fn resolve_coordinate(&mut self) {
match Coordinate::parse(self.coordinate.trim()) {
Ok(c) => {
let r = self.forge.resolve(&Query::At(c));
self.apply(r, None);
}
Err(bad) => {
self.menu = None;
self.clear_selection();
self.error = Some(format!("koordinaten {bad:?} går inte att tolka"));
}
}
}
/// Land a resolve on the one state. `preselect` picks the row whose name the
/// operator typed — that is the name matching itself, not the scene recognising a
/// product.
fn apply(&mut self, result: Result<Menu, String>, preselect: Option<&str>) {
self.clear_selection();
match result {
Ok(menu) => {
self.row = preselect.and_then(|n| menu.rows.iter().position(|r| r.name == n));
self.error = None;
self.menu = Some(menu);
self.load_settings();
}
Err(why) => {
self.menu = None;
self.error = Some(why);
}
}
}
// ── steps 3 and 4 ─────────────────────────────────────────────────────────
/// Step 3. Picking a row invalidates everything downstream of it: the column, the
/// settings, the outcome, the dependency picture and the pane below all belonged
/// to the previous row.
pub fn pick_row(&mut self, i: usize) {
if self.menu.as_ref().is_some_and(|m| i < m.rows.len()) {
self.row = Some(i);
self.target = None;
self.chosen.clear();
self.report = Report::default();
self.texture = None;
self.forget_axis_and_hunts();
}
}
/// Drop the dependency picture, so the next frame asks the forge for the picked
/// row's own. A graph left behind after a row change would be a picture of the
/// wrong assembly — and it would look exactly like a correct one.
fn forget_axis(&mut self) {
self.galois = None;
self.axis_row = None;
self.axis_note = None;
// The road belongs to the graph, not to the pane. A road line left behind
// after a row change would say "read from the warehouse" over a picture that
// has not been derived yet — the exact confident-stale claim it exists to
// prevent.
self.axis_road = None;
}
/// [`Self::forget_axis`] **and the hunts that were bought for it.**
///
/// The split is load-bearing. Buying a hunt drops the picture so the next frame
/// re-derives it — but the crates still waiting to be hunted, and the hunts
/// already paid for, belong to the SAME assembly and must survive that. Clearing
/// them in `forget_axis` made the chip strip vanish for the length of a derive
/// (so a second click landed on nothing) and made [`Self::focus_crate`]'s own
/// guard reject every crate after the first.
///
/// A row change is the other thing entirely: that assembly is gone, and spending
/// its budget on the next row's picture would be work nobody asked for.
fn forget_axis_and_hunts(&mut self) {
self.forget_axis();
self.axis_unanalysed.clear();
self.axis_focus.clear();
}
/// **Buy the minimal-cause hunt for ONE crate.**
///
/// The pane derives its picture with a budget of zero, because the hunt costs
/// 10-16 s per crate and a real pick has 98 removable ones. This is how the
/// operator spends it: name a crate, and the next derive hunts that crate and no
/// other. Hunts already bought are kept, so a second click ADDS to the picture
/// rather than replacing it — a replacing pane would never let anyone build one
/// up.
///
/// Only a crate on [`Self::axis_unanalysed`] can be asked for. Anything else
/// would widen the budget past what is on screen, and the operator would be
/// waiting on work they did not ask for.
pub fn focus_crate(&mut self, krate: &str) {
if !self.axis_unanalysed.iter().any(|c| c == krate) {
return;
}
self.axis_focus.insert(krate.to_string());
// Drop the PICTURE, not the hunts: the pick has changed, and a changed pick
// is what makes the next frame re-ask the forge.
self.forget_axis();
}
/// Ask the forge for the picked row's axis until it answers something terminal.
///
/// The pane is built ONCE per row: rebuilding it every frame would throw away the
/// operator's clicks, which is the whole state this picture is made of.
///
/// Returns whether the forge is STILL computing — the caller needs that to keep
/// asking for frames (see [`Facet::ui`]); a graph nobody repaints for never
/// arrives on screen.
fn pull_axis(&mut self) -> bool {
let Some(pick) = self.axis_pick() else {
return false;
};
if self.axis_row.as_ref() == Some(&pick) {
return false;
}
match self.forge.axis(&pick) {
Axis::Working => {
self.axis_note = Some("beroendegrafen räknas ut …".to_string());
return true;
}
Axis::Absent => {
self.axis_row = Some(pick);
self.galois = None;
self.axis_note = None;
self.axis_road = None;
}
Axis::Failed(why) => {
self.axis_row = Some(pick);
self.galois = None;
self.axis_note = Some(why);
self.axis_road = None;
}
Axis::Ready(axis) => {
self.axis_row = Some(pick);
self.axis_note = None;
// Classified HERE, off the axis the pane is about to render, so the
// road line and the graph can never be about two different reads.
// `axis_road` derives from the provenance the pane itself prints —
// there is no second field to drift (LAW 5).
self.axis_road = Some(axis_road(&axis));
// The crates nobody hunted for, carried BESIDE the graph and not
// folded into it: the pane has no way to draw "not asked yet", so
// the scene states it in its own words above the picture.
self.axis_unanalysed = axis.unanalysed.clone();
self.galois = Some(GaloisPane::new(axis.axis));
}
}
false
}
/// What the picture should currently be OF — row, column and toggles.
/// `None` when no row is picked, which is the only state with no picture.
#[must_use]
pub fn axis_pick(&self) -> Option<AxisPick> {
Some(AxisPick {
row: self.selected_row()?.name.clone(),
target: self.selected_target().map(|t| t.name.clone()),
toggles: self.toggles(),
focus: self.axis_focus.clone(),
})
}
/// **Is this pane waiting on work that happens somewhere else?**
///
/// Everything slow the pane shows — the build, the boot, the guest's serial and
/// framebuffer, the dependency graph — is produced OFF the ui thread and arrives
/// through [`Forge::poll`]/[`Forge::axis`]. egui only draws when
/// something asks it to, so while any of that is in flight the pane must ask for
/// the next frame itself or the operator sits in front of a still picture.
fn awaiting_elsewhere(&self, axis_working: bool) -> bool {
axis_working || self.report.live || matches!(self.report.phase, Some(Phase::Building))
}
/// The dependency picture, when there is one. Public so a driver can assert on
/// the SAME pane the pointer clicks rather than on a copy of its state.
#[must_use]
pub fn galois(&self) -> Option<&GaloisPane> {
self.galois.as_ref()
}
/// Route a click on the dependency graph, by node id. The headless seam onto the
/// exact path a pointer takes.
pub fn click_axis(&mut self, node_id: &str) {
if let Some(g) = self.galois.as_mut() {
g.on_click(node_id);
}
}
/// Step 4. A column that the recipe does not declare cannot be picked.
pub fn pick_target(&mut self, i: usize) {
let ok = self
.selected_row()
.and_then(|r| r.targets.get(i))
.is_some_and(|t| t.declared);
if ok {
self.target = Some(i);
self.report = Report::default();
self.texture = None;
self.load_settings();
}
}
/// Seed the editable settings from the picked target's resolved values.
fn load_settings(&mut self) {
let open: Vec<(String, String)> = self
.selected_target()
.map(|t| {
t.settings
.iter()
.filter(|s| !s.fixed)
.map(|s| (s.key.clone(), s.value.clone()))
.collect()
})
.unwrap_or_default();
self.chosen = open.into_iter().collect();
}
/// **What the pane is currently showing**, whole — the phase, the console lines and
/// the guest's captured framebuffer.
///
/// `state_json` carries the frame's SIZE but not its pixels (a megabyte of RGBA in a
/// state oracle would make every assertion on it unreadable), so a driver that wants
/// to save the guest's real screen as proof has nowhere else to read it. It reads the
/// SAME `Report` the pane draws from — not a copy that could tell a different story.
#[must_use]
pub fn report(&self) -> &Report {
&self.report
}
/// **The opt-outs as they stand right now**, keyed by option name.
///
/// Falls back to the DECLARATION's default for a toggle the operator has
/// not touched — never to `false`. An opt-out that defaulted off would let a
/// pane quietly offer an appliance stripped of capabilities nobody asked to
/// remove.
#[must_use]
pub fn toggles(&self) -> BTreeMap<String, bool> {
let Some(t) = self.selected_target() else {
return BTreeMap::new();
};
t.settings
.iter()
.filter(|s| s.declared_option.is_some())
.map(|s| {
let on = match self.chosen.get(&s.key).map(String::as_str) {
Some("true") => true,
Some("false") => false,
_ => s.value == "true",
};
(s.key.clone(), on)
})
.collect()
}
/// **The feature delta this selection would build with** — `+wg-client
/// −dhcp-client` — computed from the toggles BEFORE anything is built, so
/// the operator sees what a tick costs while deciding rather than in the
/// receipt afterwards.
///
/// The same sentence the forge's own record carries (`BuildReport::
/// feature_delta`, out of `ResolvedOptions::feature_delta`), down to the
/// U+2212 MINUS SIGN — a preview that read differently from the receipt
/// would be a second opinion about the same build.
#[must_use]
pub fn feature_delta(&self) -> String {
let Some(t) = self.selected_target() else {
return String::new();
};
let toggles = self.toggles();
let parts: Vec<String> = t
.settings
.iter()
.filter_map(|s| {
let o = s.declared_option.as_ref()?;
let on = toggles.get(&s.key).copied().unwrap_or(true);
Some(format!(
"{}{}",
if on { '+' } else { '\u{2212}' },
o.feature
))
})
.collect();
if parts.is_empty() {
String::new()
} else {
format!("features: {}", parts.join(" "))
}
}
/// **Every visible input that is not acceptable yet**, as `(key, why)`.
///
/// Only VISIBLE inputs: a required parameter belonging to a toggle state the
/// operator is not in has nothing to do with this build, and holding BYGG
/// shut for a field nobody can see is a dead button with no explanation —
/// the worst of both.
///
/// The shapes come from the DECLARATION (`DeclaredParam::check`, over the
/// kind the recipe gave — including the `ipv4` / `ipv4-cidr` an option's
/// `param_kinds` sharpens a DHCP-off static address into), so the pane
/// refuses exactly what the door would refuse, before paying for a build to
/// find out.
#[must_use]
pub fn unmet_inputs(&self) -> Vec<(String, String)> {
let Some(t) = self.selected_target() else {
return Vec::new();
};
let toggles = self.toggles();
t.settings
.iter()
.filter(|s| !s.fixed && s.visible_in(&toggles))
.filter_map(|s| {
let d = s.declared.as_ref()?;
let v = self
.chosen
.get(&s.key)
.map(String::as_str)
.unwrap_or(&s.value);
d.check(v).err().map(|why| (s.key.clone(), why))
})
.collect()
}
/// The picked row, if there is one.
#[must_use]
pub fn selected_row(&self) -> Option<&Row> {
let (m, i) = (self.menu.as_ref()?, self.row?);
m.rows.get(i)
}
/// The picked column, if there is one.
#[must_use]
pub fn selected_target(&self) -> Option<&Target> {
self.selected_row()?.targets.get(self.target?)
}
/// Which pane a build would open. `None` until both selections are made — the
/// scene refuses to guess a pane out of half a selection.
#[must_use]
pub fn pane(&self) -> Option<PaneKind> {
self.selected_target().map(Target::pane)
}
/// Step 5 is available exactly when steps 3 and 4 both landed.
#[must_use]
/// **Is BYGG armed?** A picked column, and every visible declared input
/// acceptable.
///
/// Until 2026-08-22 this asked only whether a column was picked, so an
/// installer with a REQUIRED parameter left blank armed the button, paid for
/// a compose, and failed at the door. The refusal is the same one either
/// way; the difference is whether the operator learns it in a second or
/// after a build.
pub fn can_build(&self) -> bool {
self.selected_target().is_some() && self.unmet_inputs().is_empty()
}
// ── step 5 ────────────────────────────────────────────────────────────────
/// Step 5. A no-op unless a row AND a column are picked.
/// **The site values AS THEY STAND** — the picked column's declared settings with
/// the operator's own edits over them. One spelling, because `BYGG` and
/// `BYGG <kant>` must send the forge the same answer to "what did they choose";
/// two gatherings would be two builds of two different things off one screen.
fn settings_now(&self) -> BTreeMap<String, String> {
let mut settings: BTreeMap<String, String> = self
.selected_target()
.map(|t| {
t.settings
.iter()
.map(|s| (s.key.clone(), s.value.clone()))
.collect()
})
.unwrap_or_default();
settings.extend(self.chosen.iter().map(|(k, v)| (k.clone(), v.clone())));
settings
}
pub fn build(&mut self) {
let Some(row) = self.selected_row().map(|r| r.name.clone()) else {
return;
};
let Some(target) = self.selected_target() else {
return;
};
let name = target.name.clone();
let settings = self.settings_now();
self.texture = None;
self.forge.act(Act::Build {
row,
target: name,
settings,
});
}
/// A pane verb. Refused unless a build has produced something for it to act on —
/// a start button that starts nothing is worse than no button.
pub fn act(&mut self, act: Act) {
if self.report.outcome.is_some() {
self.forge.act(act);
}
}
/// **Poll the forge, and take a re-answered matrix if it hands one up.**
///
/// The one place `Forge::poll` is called from, so a matrix that changes under the
/// scene — the recipe crate's wasm landed and the CODE now answers where the table
/// did (gap #9) — is swapped in on every surface that polls, never on one and not
/// the other.
fn take_report(&mut self) {
let mut report = self.forge.poll();
if let Some(menu) = report.menu.take() {
self.swap_menu(menu);
}
self.report = report;
self.settle_run_receipt();
// The chained build's steps, when a runner is reporting them.
if let Some(steps) = self.forge.steps() {
self.steps.apply(&steps);
}
}
/// **Replace the matrix, keeping the operator's place by NAME.** The row, the
/// column and every edit whose key the new matrix still has survive; an index
/// would not, because the new answer may order or count its rows differently.
fn swap_menu(&mut self, menu: Menu) {
let row_name = self.selected_row().map(|r| r.name.clone());
let target_name = self.selected_target().map(|t| t.name.clone());
let kept = std::mem::take(&mut self.chosen);
self.row = row_name.and_then(|n| menu.rows.iter().position(|r| r.name == n));
self.target = match (self.row, target_name) {
(Some(i), Some(t)) => menu.rows[i]
.targets
.iter()
.position(|x| x.name == t && x.declared),
_ => None,
};
self.menu = Some(menu);
self.load_settings();
for (k, v) in kept {
if self.chosen.contains_key(&k) {
self.chosen.insert(k, v);
}
}
// The picture belonged to the previous answer's row.
self.forget_axis_and_hunts();
}
/// Drop every selection, and the pane with it.
fn clear_selection(&mut self) {
self.row = None;
self.target = None;
self.chosen.clear();
self.report = Report::default();
self.texture = None;
self.forget_axis_and_hunts();
}
// ── render ────────────────────────────────────────────────────────────────
/// **The once-a-frame prologue — poll the forge, pull the axis, ask for the next
/// frame — run EXACTLY once however many surfaces draw this pane.**
///
/// Whole-mounted, `Facet::ui` is one call and this is trivially once. Mounted as the
/// ⚒ scene's five LEAVES it is not: the host calls `ui_leaf` up to five times a
/// frame, and polling a forge five times to draw one frame is five answers where
/// there is one question — worse, `pull_axis` is a state machine that must not be
/// stepped five times per frame. So the pass number is the guard, not the call site:
/// folding a leaf away cannot skip the prologue and unfolding one cannot double it.
///
/// ── ASK FOR THE NEXT FRAME WHILE THE WORK IS ELSEWHERE ────────────────────
///
/// **MEASURED 2026-08-16 on the SHIPPED ui binary**, on a real X server: pick an
/// installer column, click BYGG, and the pane freezes at `BYGGER 0.3s` and stays
/// there. Three minutes later the build had packed its payload, compiled its first
/// stage and was mastering the medium — and the window still read `0.3s`. Nothing
/// was broken except that nobody had asked egui to draw. Nudging the mouse repainted
/// it and the whole run appeared at once: `KLAR 237.4s`, the receipt, the serial, the
/// guest's framebuffer.
///
/// So the operator's actual experience of this pane was: press the button, watch it
/// die, and get the result only if you happen to move the pointer. The serial and the
/// graphical console are worse than the timer — a live install scrolls past and the
/// frames it draws are never shown at all.
///
/// **No test in this tree could see it, and none ever will.** Every drive here runs
/// on `egui_kittest`, whose `Harness::run()` pumps frames unconditionally; a harness
/// that always redraws cannot notice a pane that never asks to be redrawn. That is
/// the shape of the hollow green (LAW 2) — the guard is blind to the one thing that
/// was wrong — and the reason the check that belongs with this fix is
/// `the_pane_asks_for_frames_while_work_is_in_flight` below, which asserts on the
/// REQUEST rather than on a repaint nobody can observe offscreen.
///
/// 120 ms is `deploy_key.rs`'s interval, and the same reasoning: fast enough that a
/// timer looks like a timer, slow enough to cost nothing when idle. It is
/// `request_repaint_after`, not `request_repaint`, so a settled pane sleeps.
fn begin_pass(&mut self, ui: &Ui) {
let pass = ui.ctx().cumulative_pass_nr();
if self.pass == Some(pass) {
return;
}
self.pass = Some(pass);
self.take_report();
let axis_working = self.pull_axis();
if self.awaiting_elsewhere(axis_working) {
ui.ctx().request_repaint_after(WORKING_REPAINT);
}
}
/// Steps 1 and 2: the two doors, and what answered.
fn draw_entry(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
// `labelled_by` is what puts the word "artefakt" on the field's AccessKit
// node, and that is the only handle a robot has to type into it. Without it
// the field is reachable by a human and by nothing else.
let caption = ui.label("artefakt");
let field = ui
.add(
egui::TextEdit::singleline(&mut self.artifact)
.hint_text("namnet på det som ska byggas")
.desired_width(240.0),
)
.labelled_by(caption.id);
let entered = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui.button("sök").clicked() || entered {
self.resolve_artifact();
}
});
ui.horizontal(|ui| {
let caption = ui.label("recept");
match &self.menu {
Some(m) => {
ui.label(RichText::new(m.coordinate.to_string()).strong());
ui.label(RichText::new(&m.how).weak());
}
None => {
let field = ui
.add(
egui::TextEdit::singleline(&mut self.coordinate)
.hint_text("krate:version[:hash]")
.desired_width(240.0),
)
.labelled_by(caption.id);
let entered =
field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui.button("peka ut").clicked() || entered {
self.resolve_coordinate();
}
}
}
});
if let Some(e) = &self.error {
ui.colored_label(RED, e);
}
}
/// Step 3: the home matrix, one row selectable at a time.
fn draw_rows(&mut self, ui: &mut Ui) {
let Some(menu) = self.menu.as_ref() else {
return;
};
section(ui, "varianter");
if menu.rows.is_empty() {
ui.colored_label(AMBER, "receptet deklarerar inga rader");
return;
}
let mut pick = None;
// A recipe's row count is the recipe's, not the window's: a real appliance
// recipe declares enough rows that one line each pushed the picked cell's
// MENU below the surface (measured 2026-09-26: an iso column's tunnel toggle
// laid out at y=1403 in a 1400 px pane). The strip is bounded and scrolls, like the
// canvas it sits beside, so the controls it leads to stay reachable.
egui::ScrollArea::vertical()
.id_salt("build-scene-rows")
.max_height(ROWS_STRIP_MAX_H)
.auto_shrink([false, true])
.show(ui, |ui| {
for (i, row) in menu.rows.iter().enumerate() {
let selected = self.row == Some(i);
ui.horizontal(|ui| {
let r = ui.selectable_label(selected, RichText::new(&row.name).monospace());
if r.clicked() {
pick = Some(i);
}
for b in &row.badges {
ui.label(RichText::new(b).weak().small());
}
});
}
});
if let Some(i) = pick {
self.pick_row(i);
}
}
/// Step 4: the columns of the picked row — the second half of the MATRIX.
///
/// This used to run on to draw the settings, the configurator leaf, the dependency
/// picture and BYGG as well, which is why the tab was one flow: five regions that
/// answer five different questions, in one function, with nothing between them an
/// operator could drag. They are separate methods now ([`Self::draw_settings`],
/// [`Self::draw_leaf`], [`Self::draw_run`]) so each can be a LEAF of the ⚒ scene —
/// and `Facet::ui` still calls all of them in exactly this order, so the pane
/// mounted whole draws byte-for-byte what it drew before.
fn draw_targets(&mut self, ui: &mut Ui) {
let Some(row) = self.selected_row() else {
return;
};
section(ui, "mål");
let targets: Vec<(String, bool)> = row
.targets
.iter()
.map(|t| (t.name.clone(), t.declared))
.collect();
let mut pick = None;
ui.horizontal_wrapped(|ui| {
for (i, (name, declared)) in targets.iter().enumerate() {
let selected = self.target == Some(i);
let text = RichText::new(name).monospace();
let text = if *declared { text } else { text.color(GREY) };
let r = ui
.add_enabled_ui(*declared, |ui| ui.selectable_label(selected, text))
.inner;
if !*declared {
r.on_hover_text("receptet deklarerar ingen sådan cell för den här raden");
} else if r.clicked() {
pick = Some(i);
}
}
});
if let Some(i) = pick {
self.pick_target(i);
}
}
/// Step 5: the dependency picture and the door — `▶ BYGG`, and why it is shut.
///
/// Split out of [`Self::draw_targets`] so the ⚒ settings leaf can host it: what a
/// feature costs and the button that spends it belong to the same surface, and the
/// picture is drawn BEFORE the build precisely so the operator reads it while still
/// deciding.
fn draw_run(&mut self, ui: &mut Ui) {
// The same guard `draw_targets` used to hold for this block. With no row picked
// there is no build to arm and no picture to draw; the ⚒ matrix leaf is where
// the operator is told to pick one, and a BYGG button that appears before there
// is anything to build is a door to nowhere.
if self.selected_row().is_none() {
return;
}
self.draw_axis(ui);
ui.horizontal(|ui| {
let building = self.report.phase == Some(Phase::Building);
let armed = self.can_build() && !building;
if ui
.add_enabled(armed, egui::Button::new(RichText::new("BYGG").strong()))
.clicked()
{
self.build();
}
// ── WHY THE BUTTON IS SHUT, ALWAYS ──────────────────────────────
//
// A disabled button with no reason beside it is the same defect as a
// silent empty roster: the operator can see that nothing happens and
// cannot see why. "välj en rad och en kolumn" was the only sentence
// here, and it was the WRONG one the moment a column was picked and
// a required input was blank.
if !self.can_build() {
if self.selected_target().is_none() {
ui.label(RichText::new("välj en rad och en kolumn").weak());
} else {
let unmet = self.unmet_inputs();
ui.colored_label(
RED,
unmet
.iter()
.map(|(k, why)| format!("{k}: {why}"))
.collect::<Vec<_>>()
.join(" · "),
);
}
}
});
// The feature delta this selection would build with — beside the button,
// while the operator is still deciding, in the same words the receipt
// will use afterwards.
let delta = self.feature_delta();
if !delta.is_empty() {
ui.label(RichText::new(delta).monospace().weak().small());
}
}
/// **The forge's own configurator, drawn where the choice is made.**
///
/// **What a feature costs, drawn BEFORE the build** — above the button, not
/// after the fact.
///
/// This is the question an operator actually has at this point: click a package
/// away and see which feature goes dark; switch a feature off and see which
/// packages vanish. Both directions come off the ONE model this pane holds, so
/// they cannot disagree — a second table for the reverse direction is a second
/// thing that has to be kept true.
///
/// Its height is fixed. A graph that grows with its own content pushes the build
/// button off the bottom of a long matrix, and a button you have to scroll for is
/// a button that is not in front of the operator.
fn draw_axis(&mut self, ui: &mut Ui) {
if self.selected_row().is_none() {
return;
}
// ── THE FRAME IS ALWAYS HERE, AND THAT IS THE FLICKER FIX ────────────
//
// This used to return early whenever there was no graph, so the header
// and its 220-420 px of space appeared and disappeared as the picture
// arrived, failed, or was invalidated by a pick. Everything below it
// jumped with it — including BYGG. A header that is always drawn, with
// the reason inside it when there is nothing to draw, keeps the layout
// still and says which of the three states this is.
let note = self.axis_note.clone();
let road = self.axis_road.clone();
let unanalysed = self.axis_unanalysed.clone();
// Which crate the operator asked to hunt, collected inside the closure and
// acted on after it — `self` is borrowed by `pane` for the whole of the
// body, and a hunt is a change to the PICK, not to the picture in front of
// it.
let mut asked: Option<String> = None;
// ── NO SECOND GRAPH PANE (owner, 2026-08-26) ───────────────────────────
// The dependency picture used to be its own graph widget here. It is part
// of the ONE build graph now — every feature's crates a folded subgraph under
// it, the emphasis edges the same — so this header carries only the words:
// where the picture came from, what has not been hunted, and what the current
// opt-outs cost.
let consequence = self.galois.as_ref().map(GaloisPane::consequence);
// The picture's nodes as accessible CHIPS — the clicks the old canvas offered
// (a feature toggles, a crate is dropped, a conjunction breaks) survive here by
// label, routed to the same model the graph paints.
let chips: Vec<(String, String)> = self
.galois
.as_ref()
.map(|g| {
g.model()
.0
.nodes
.iter()
.map(|n| (n.id.clone(), n.label.clone()))
.collect()
})
.unwrap_or_default();
let mut chip_clicked: Option<String> = None;
egui::CollapsingHeader::new(RichText::new("beroenden").strong())
.id_salt("build-scene-axis")
.default_open(true)
.show(ui, |ui| {
draw_axis_road(ui, road.as_ref());
if let Some(clicked) = draw_unanalysed(ui, &unanalysed) {
asked = Some(clicked);
}
if !chips.is_empty() {
ui.horizontal_wrapped(|ui| {
for (id, label) in &chips {
if ui
.selectable_label(false, RichText::new(label).monospace().small())
.clicked()
{
chip_clicked = Some(id.clone());
}
}
});
}
match consequence {
Some(c) => {
ui.label(
RichText::new("bilden är delgrafen under varje feature i byggrafen")
.weak()
.small(),
);
if !c.is_empty() {
ui.label(RichText::new(c).small());
}
}
None => {
ui.label(
RichText::new(
note.as_deref()
.unwrap_or("ingen beroendegraf för det här valet"),
)
.weak()
.small(),
);
}
}
});
if let Some(krate) = asked {
self.focus_crate(&krate);
}
if let Some(id) = chip_clicked {
self.click_axis(&id);
}
}
/// **The build GRAPH** — the settings tree drawn as the graph it is (the owner,
/// 2026-08-26: "byt"): a node per variant, a node per feature on it, containment
/// and CONSUMPTION edges, a feature with children folded until clicked. Lowered by
/// [`crate::build_graph::lower`] from the menu and the picked variant's tree
/// ([`Forge::tree`]), painted by the ONE graph renderer this crate has
/// ([`crate::dag_graph::DagPane`]).
///
/// The spec is rebuilt only when what it is a picture OF changes (the recipe, the
/// pick, the values) — the pane keeps its pan, zoom, folds and selection across a
/// rebuild, because a graph that re-fits itself on every click is a graph nobody can
/// read. A canvas click SELECTS: a variant node picks its row and column in the
/// matrix; a feature node picks its variant and opens that node's menu; a folded
/// node unfolds. The variant/column strips beneath stay as the accessible index —
/// every click that existed still exists, and a robot addresses nodes by label.
fn draw_graph(&mut self, ui: &mut Ui, canvas_first: bool) {
let Some(menu) = self.menu.clone() else {
self.tree = None;
self.graph_key.clear();
return;
};
// ── the picked variant's tree, with the values as they stand ─────────────
let row = self.selected_row().map(|r| r.name.clone());
let target = self.selected_target().map(|t| t.name.clone());
let declared = self
.forge
.tree(row.as_deref(), target.as_deref(), &self.chosen);
let symbols: BTreeMap<String, bool> = self
.chosen
.iter()
.filter(|(k, _)| k.starts_with(&format!("{KERNEL_PATH}.")))
.map(|(k, v)| (k.clone(), v == "true"))
.collect();
let view = declared.as_ref().map(|d| {
TreeView::of(
d,
&Standing {
chosen: &self.chosen,
symbols: &symbols,
},
)
});
// ── rebuild the spec only when the picture changes ───────────────────────
let key = format!(
"{}|{:?}|{:?}|{}",
menu.coordinate,
row,
target,
view.as_ref()
.map(|v| v.state_json().to_string())
.unwrap_or_default()
) + &self
.galois
.as_ref()
.map(|g| format!("|deps:{:?}|{}", g.disabled(), g.axis().analyses.len()))
.unwrap_or_default();
if key != self.graph_key {
let mut folded = Vec::new();
let trees: Vec<crate::build_graph::VariantTree<'_>> = declared
.iter()
.filter(|d| !d.variant.is_empty())
.map(|d| crate::build_graph::VariantTree {
variant: &d.variant,
tree: d,
})
.collect();
let deps = self.galois.as_ref().map(|g| g.model());
let deps_ref = deps.as_ref().map(|(m, d)| (m, d));
let spec = crate::build_graph::lower(&menu, &trees, deps_ref, &mut folded);
self.graph.set_spec(&spec);
// Folds are the operator's: a node they opened stays open across a rebuild;
// a node they never touched starts folded.
for id in folded {
if !self.graph_open.contains(&id) {
self.graph.fold(&id);
}
}
self.graph.select(self.graph_selected.clone());
self.graph_key = key;
}
self.tree = view;
// ── the strips pick ⇒ the graph follows ─────────────────────────────────
// A pick made through the accessible strips selects the variant node, unless a
// node of that variant's tree is the menu's subject already.
if let Some(variant) = self.picked_variant() {
let want = match &self.menu_node {
Some(p) => crate::build_graph::node_id(&variant, p),
None => crate::build_graph::variant_id(&variant),
};
if self.graph_selected.as_deref() != Some(want.as_str()) {
self.graph.select(Some(want.clone()));
self.graph_selected = Some(want);
}
}
if canvas_first {
self.draw_graph_canvas(ui);
self.draw_rows(ui);
self.draw_targets(ui);
} else {
self.draw_rows(ui);
self.draw_targets(ui);
}
}
/// **The canvas**: paint the graph, then read the click. Hosted, it sits above the
/// strips in the graph leaf; whole-mounted it is drawn LAST, below everything the
/// operator (and every existing robot) reaches by label — a canvas that pushes the
/// controls below the surface is a canvas nobody can click past.
fn draw_graph_canvas(&mut self, ui: &mut Ui) {
if self.menu.is_none() {
return;
}
let h = (ui.available_height() - 120.0).max(200.0);
ui.allocate_ui(egui::vec2(ui.available_width(), h), |ui| self.graph.ui(ui));
let now = self.graph.selected().map(str::to_string);
if now != self.graph_selected {
self.graph_selected = now.clone();
if let Some(id) = now {
self.on_graph_select(&id);
}
}
if let Some(v) = self
.tree
.as_ref()
.and_then(|t| t.kernel().map(|k| k.children.iter().any(|c| c.symbol)))
{
if v {
ui.label(RichText::new(crate::kernel_scene::REFLECTS).weak().small());
}
}
}
/// **The build graph, as data** — what is selected, what the operator has opened,
/// how big the picture is and how much of it is on screen.
fn graph_json(&self) -> Value {
if self.menu.is_none() {
return Value::Null;
}
json!({
"selected": self.graph_selected,
"open": self.graph_open.iter().collect::<Vec<_>>(),
"nodes": self.graph.node_count(),
"edges": self.graph.edge_count(),
"visible": self.graph.visible_ids().len(),
"ids": self.graph_ids(),
})
}
/// Every node id in the current spec, from the engine's own model.
fn graph_ids(&self) -> Vec<String> {
self.graph
.state_json()
.get("nodes")
.and_then(|n| n.as_array())
.map(|a| {
a.iter()
.filter_map(|n| n.get("id").and_then(|i| i.as_str()).map(str::to_string))
.collect()
})
.unwrap_or_default()
}
/// **The menu, as data** — its subject (the picked variant, and the node within it
/// when one is), and every control it shows with the path and flag beside it.
fn menu_json(&self) -> Value {
let standing = self
.tree
.as_ref()
.map(|t| t.variant.clone())
.filter(|v| !v.is_empty());
let Some(variant) = self.picked_variant().or(standing) else {
return Value::Null;
};
let view = self.tree.clone().unwrap_or_default();
let view = &view;
let controls: Vec<Value> = match &self.menu_node {
// The roots, each followed by the leaf children the menu draws under it
// (under `kärna` only the vals) — the same rule `draw_control` follows.
None => view
.roots
.iter()
.flat_map(|n| {
let is_kernel = n.path == KERNEL_PATH;
let on = n.is_on();
std::iter::once(control_json(n)).chain(
n.children
.iter()
.filter(move |c| {
c.when.is_none_or(|w| w == on)
&& c.children.is_empty()
&& (!is_kernel || c.kind == "val")
})
.map(control_json),
)
})
.collect(),
Some(p) => view
.find(p)
.map(|n| {
std::iter::once(control_json(n))
.chain(n.children.iter().map(control_json))
.collect()
})
.unwrap_or_default(),
};
json!({
"variant": variant,
"typ": view.typ.as_str(),
"node": self.menu_node,
"note": self.menu_note,
"controls": controls,
// After a build: the receipt readable, and the verbs the typ bears.
"buildinfo": self.buildinfo_json(),
"actions": self.actions_json(),
"run_note": self.run_note,
// The BYGG verbs on the menu — exactly one per node with a consumption
// edge, each with the CLI line it equals; and why a kernel-consuming kind
// without an edge has none.
"build_verbs": self.build_verbs,
"no_edge_why": self.no_edge_why,
})
}
// ── BYGG <kant>: the edge DAG (wave 3, gap #4) ────────────────────────────
/// **Why a kernel-consuming kind offers no BYGG** — an iso or an installer whose
/// tree carries no consumption edge. Said by name, and WHOSE decision it is: an
/// edge on a product's medium (`[build.<kind>.kernel_donor]`) is the product
/// owner's — the owner's — not the forge's to add; until then the medium boots the
/// host's kernel and the refusal says so instead of offering a verb to nowhere.
fn draw_no_edge(&mut self, ui: &mut Ui, view: &TreeView) {
if !view.typ.takes_kernel_donor() {
return;
}
fn has_edge(n: &NodeView) -> bool {
n.edge.is_some() || n.children.iter().any(has_edge)
}
if view.roots.iter().any(has_edge) {
return;
}
let why = format!(
"`{}` deklarerar ingen konsumtionskant — inget BYGG <kant> här. En kärnkant på en \
iso/installer ([build.<kind>.kernel_donor]) är produktägarens beslut, inte \
smedjans; utan den bootar mediet värdens kärna",
view.variant
);
ui.colored_label(AMBER, &why);
self.no_edge_why = Some(why);
}
/// The verb's word: `BYGG kärnan` on the kärna node (the design's own spelling,
/// `[BYGG kärnan]` ≡ `--build kernel`), `BYGG <label>` on any other edge node.
fn edge_verb_label(n: &NodeView) -> String {
if n.path == KERNEL_PATH {
"BYGG kärnan".to_string()
} else {
format!("BYGG {}", n.label)
}
}
/// **The scoped `--opt` line for an edge node** — its subtree as it stands: every
/// val's choice (`kernel.preset`), every feature clicked OFF (a veto), every setting
/// with a value. The strings are the tree's own flags (`flags_of` spelling) — the
/// very ones beside the controls (law C) — so the line the menu shows is the line
/// the CLI door judges.
fn scoped_flags(n: &NodeView) -> Vec<String> {
fn walk(n: &NodeView, out: &mut Vec<String>) {
for c in &n.children {
match (c.kind, &c.flag) {
("val", Some(f)) => out.push(f.clone()),
("feature", Some(f)) if !c.is_on() => out.push(f.clone()),
("setting", Some(f)) if !c.value.trim().is_empty() => out.push(f.clone()),
_ => {}
}
walk(c, out);
}
}
let mut out = Vec::new();
walk(n, &mut out);
out
}
/// **The BYGG verb on an edge node** — the button, and beside it the exact CLI line
/// it equals. Disabled while something builds or runs; never absent on an edge,
/// never present without one.
fn draw_edge_verb(&mut self, ui: &mut Ui, n: &NodeView, variant: &str) {
let Some(edge) = n.edge.as_deref() else {
return;
};
let (coordinate, typ) = split_edge(edge);
let label = Self::edge_verb_label(n);
// ★ THE PRINTED LINE IS THE CLICK'S OWN PLAN, not a second spelling beside it.
//
// This used to call `cli_line` with `Self::scoped_flags(n)` — the flags UNDER the
// clicked edge node — while the button sent `edge_plan`, whose consumer step
// carries the whole tree's line. So every cell-level opt-out was printed away:
// the line under the button named the `--build` target and the kernel subtree's
// options and nothing else, while the click was also sending the cell's own
// `--opt <capability>=off`. An operator who pasted that line got the capability
// COMPILED BACK IN. Law C at this axis is path ↔ flag; a printed command that
// does not reproduce the click breaks it worse than no command would.
//
// Building the plan here is the fix BY CONSTRUCTION: the line drawn and the plan
// handed to `Forge::build_edge` are now the same object, so they cannot drift.
// `edge_plan` resolves nothing — it reads the tree and the settings map — so it
// is a per-frame cost of the same order as `scoped_flags` was.
let Some(plan) = self.edge_plan(variant, n) else {
return;
};
let cli = plan.cli.clone();
self.build_verbs.push(json!({
"label": label,
"path": n.path,
"coordinate": coordinate,
"typ": typ.as_str(),
"cli": cli,
}));
let busy = self.report.live || self.report.phase == Some(Phase::Building);
if ui
.add_enabled(!busy, egui::Button::new(RichText::new(&label).strong()))
.clicked()
{
self.bygg_edge(variant, n);
}
ui.label(RichText::new(cli).monospace().small());
}
/// **`BYGG <kant>` fired.** Plan the DAG — the donor with the scoped line first, the
/// consumer with its own flags last — stage it in the step console with the honest
/// expectation, then hand the plan to the forge. A refusal lands on every step BY
/// NAME and starts no clock: a forge without the runner says
/// [`crate::build_steps::missing_runner`], never a silent no-op.
/// The two-step this click stages BEFORE the forge answers — the picture drawn
/// while the planner is asked. One spelling, shared by `BYGG` and its dry twin.
fn edge_plan(&mut self, variant: &str, n: &NodeView) -> Option<crate::build_steps::StepPlan> {
let edge = n.edge.as_deref()?;
let (coordinate, typ) = split_edge(edge);
let variant_typ = self.tree.as_ref().map(|t| t.typ).unwrap_or_default();
let consumer_flags = self.tree.as_ref().map(TreeView::flags).unwrap_or_default();
let product = self.forge.product();
let mut plan = crate::build_steps::StepPlan::for_edge(
product.as_deref(),
variant,
variant_typ,
&n.path,
&coordinate,
typ,
Self::scoped_flags(n),
consumer_flags,
);
// The site values the CONSUMER's own step needs (`--param`): the same
// gathering `BYGG` sends, so the chained road and the direct road build the
// same artifact out of the same screen.
plan.settings = self.settings_now();
// The DECLARED ones among them, off the forge's own reader — the plan carries
// them so the printed line and the sent build take one answer, not two.
plan.params = self.forge.declared_params(variant, &plan.settings);
plan.respell_cli();
Some(plan)
}
fn bygg_edge(&mut self, variant: &str, n: &NodeView) {
let Some(plan) = self.edge_plan(variant, n) else {
return;
};
let label = Self::edge_verb_label(n);
self.steps.stage(plan.clone());
match self.forge.build_edge(&plan) {
// The forge answers the plan it started — the whole DAG, not this
// node's two steps. Restage on it, then start the clock.
Ok(actual) => {
self.steps.stage(actual);
self.steps.started(&label);
}
Err(why) => self.steps.refused(&label, &why),
}
}
/// **The chain this edge would run, planned and NOT started** — the pane's dry
/// pass. Stages the planner's own DAG in the console with every step queued and
/// no clock; a refusal lands on the console exactly as `BYGG` would spell it.
fn plan_edge(&mut self, variant: &str, n: &NodeView) {
let Some(plan) = self.edge_plan(variant, n) else {
return;
};
let label = Self::edge_verb_label(n);
self.steps.stage(plan.clone());
match self.forge.plan_edge(&plan) {
Ok(actual) => self.steps.stage(actual),
Err(why) => self.steps.refused(&label, &why),
}
}
/// **`BYGG <kant>` by path** — the headless twin of the button, for robots: the node
/// at `path` in the menu's tree, when it carries an edge.
pub fn bygg_edge_at(&mut self, path: &str) -> bool {
let standing = self
.tree
.as_ref()
.map(|t| t.variant.clone())
.filter(|v| !v.is_empty());
let Some(variant) = self.picked_variant().or(standing) else {
return false;
};
let Some(n) = self.tree.as_ref().and_then(|t| t.find(path).cloned()) else {
return false;
};
if n.edge.is_none() {
return false;
}
self.bygg_edge(&variant, &n);
true
}
/// **The DRY twin of [`bygg_edge_at`](Self::bygg_edge_at)** — the chain planned
/// into the console, nothing spawned. The pane's `--list --build`.
pub fn plan_edge_at(&mut self, path: &str) -> bool {
let standing = self
.tree
.as_ref()
.map(|t| t.variant.clone())
.filter(|v| !v.is_empty());
let Some(variant) = self.picked_variant().or(standing) else {
return false;
};
let Some(n) = self.tree.as_ref().and_then(|t| t.find(path).cloned()) else {
return false;
};
if n.edge.is_none() {
return false;
}
self.plan_edge(&variant, &n);
true
}
/// **Stage a step plan from data** — the fixture door: a `StepPlan` as JSON, states
/// included, so the console renders every state without a forge.
///
/// # Errors
/// The serde error when the JSON is not a plan.
pub fn stage_steps(&mut self, plan: &Value) -> Result<(), String> {
self.steps.stage_json(plan)
}
/// The step console, as data.
#[must_use]
pub fn steps_json(&self) -> Value {
self.steps.state_json()
}
/// The step console: the planned DAG when there is one; hosted (the console leaf),
/// one honest line when there is none.
fn draw_steps(&mut self, ui: &mut Ui) {
if self.steps.plan().is_none() && !self.hosted {
return;
}
self.steps.ui(ui);
if self.steps.plan().is_some() {
ui.separator();
}
}
/// **Select a graph node by id** — the headless twin of a canvas click, for robots
/// and benches (the graph engine's select message has the same shape). A variant
/// node picks its row and column; a tree node picks its variant and becomes the
/// menu's subject; a folded node unfolds.
pub fn select_node(&mut self, id: &str) {
self.graph.select(Some(id.to_string()));
self.graph_selected = Some(id.to_string());
self.on_graph_select(id);
}
/// Fold a graph node's subtree again (the menu's "▸" twin).
pub fn fold_node(&mut self, id: &str) {
self.graph.fold(id);
self.graph_open.remove(id);
}
/// The build graph's pane (camera, positions, where it sits on screen) — what a
/// robot reads to check the picture is centred and a click never moved it.
#[must_use]
pub fn graph_pane(&self) -> &crate::dag_graph::DagPane {
&self.graph
}
/// The build graph's pane, mutable (a host or robot sets 📌 Stay / 🎯 Focus).
pub fn graph_pane_mut(&mut self) -> &mut crate::dag_graph::DagPane {
&mut self.graph
}
/// The graph pane's own measurement — nodes, edges, last frame cost.
#[must_use]
pub fn graph_report(&self) -> Value {
self.graph.report()
}
/// The variant the pick names — the delivery variant of the picked column, else
/// the row itself.
fn picked_variant(&self) -> Option<String> {
let row = self.selected_row()?;
Some(
match self.selected_target().and_then(|t| t.variant.clone()) {
Some(v) => v,
None => row.name.clone(),
},
)
}
/// **A canvas click landed on `id`.** A variant node ⇒ the matrix pick it names;
/// a tree node ⇒ its variant picked and the node made the menu's subject; a folded
/// node ⇒ unfolded in place. A donor's coordinate (another repo's variant) ⇒ the
/// menu says where it is built.
fn on_graph_select(&mut self, id: &str) {
// A node of the dependency picture: the click goes to the feature↔crate model
// (toggle a feature, drop a crate, break a conjunction) — the same path the
// old picture's canvas took — and the picture re-lowers into the graph.
if let Some((_, inner)) = id.split_once(crate::build_graph::DEPS_SEP) {
let inner = inner.to_string();
self.click_axis(&inner);
if !self.graph.is_expanded(id) {
self.graph.unfold(id);
self.graph_open.insert(id.to_string());
}
return;
}
let (variant, path) = crate::build_graph::split_id(id);
let (variant, path) = (variant.to_string(), path.map(str::to_string));
// Which (row, column) names this variant.
let pick = self.menu.as_ref().and_then(|m| {
m.rows.iter().enumerate().find_map(|(i, r)| {
if r.name == variant {
let j = r
.targets
.iter()
.position(|t| t.declared && t.variant.is_none());
return Some((i, j));
}
r.targets
.iter()
.position(|t| t.declared && t.variant.as_deref() == Some(variant.as_str()))
.map(|j| (i, Some(j)))
})
});
match pick {
Some((i, j)) => {
if self.row != Some(i) {
self.pick_row(i);
}
if let Some(j) = j {
if self.target != Some(j) {
self.pick_target(j);
}
}
self.menu_node = path.clone();
self.menu_note = None;
}
None => {
// Not a variant of this recipe: a consumed coordinate (`<donor>/<kernel>`).
self.menu_node = None;
self.menu_note = Some(format!(
"`{variant}` byggs i sitt eget recept — sök det som rot för att välja där; \
här är den en konsumtionskant (--build {variant})"
));
}
}
if path.is_some() && !self.graph.is_expanded(id) {
self.graph.unfold(id);
self.graph_open.insert(id.to_string());
}
}
/// **The selected node's MENU** — its own settings rendered from its node (feature →
/// checkbox, val → strip, setting → field), each with its PATH and the flag it equals
/// (law C on screen), then the verbs the typ bears (BYGG here; RUN / BOOT on the
/// console once something is built) and the dependency picture above ▶.
///
/// A variant node's menu is its roots; a feature node's is the node and its direct
/// children — a child with children of its own is a SUBGRAPH, named here and opened
/// in the graph. The `kärna` node's menu wears the honesty label.
fn draw_menu(&mut self, ui: &mut Ui) {
if self.menu.is_none() {
ui.label(RichText::new("sök en rot först — receptet väljer grafen").weak());
return;
}
// The picked variant — or, with nothing picked, the STANDING question's variant
// (the one that carries `kärna`), so the donor is named before a click.
let standing = self
.tree
.as_ref()
.map(|t| t.variant.clone())
.filter(|v| !v.is_empty());
let Some(variant) = self.picked_variant().or(standing) else {
ui.label(RichText::new("välj en nod i grafen").weak());
return;
};
let view = self.tree.clone().unwrap_or_default();
// The BYGG verbs are re-collected by the controls that draw them this frame.
self.build_verbs.clear();
self.no_edge_why = None;
ui.horizontal(|ui| {
ui.label(RichText::new(&variant).strong().monospace());
ui.label(RichText::new(view.typ.as_str()).weak().small());
if let Some(p) = &self.menu_node {
ui.label(RichText::new(format!("▸ {p}")).monospace());
}
});
if let Some(n) = &self.menu_note {
ui.colored_label(AMBER, n);
}
self.draw_no_edge(ui, &view);
match self.menu_node.clone() {
None => {
self.draw_fixed_facts(ui);
for n in view.roots.clone() {
self.draw_control(ui, &n, 0, &variant);
}
}
Some(p) => match view.find(&p).cloned() {
Some(n) => {
self.draw_control(ui, &n, 0, &variant);
if n.path == KERNEL_PATH {
ui.label(RichText::new(crate::kernel_scene::REFLECTS).weak().small());
}
let on = n.is_on();
for c in n.children.iter().filter(|c| c.when.is_none_or(|w| w == on)) {
self.draw_control(ui, c, 1, &variant);
}
}
None => {
ui.colored_label(AMBER, format!("`{p}` är inte en nod i `{variant}`s träd"));
}
},
}
self.draw_run(ui);
self.draw_buildinfo(ui);
self.draw_verbs(ui);
}
/// **The typ the last build produced**, read off the BUILD receipt (gap #7: the typ
/// on the receipt decides the verbs) — falling back to the picked column's declared
/// kind only for a build that produced no readable receipt. `None` before a build.
fn built_typ(&self) -> Option<ArtifactKind> {
let o = self.report.outcome.as_ref()?;
if !o.ok {
return None;
}
Some(match &o.receipt {
Some(r) if !r.target.trim().is_empty() => crate::actions::typ_of(&r.target),
_ => self.selected_target()?.kind,
})
}
/// **BUILDINFO — always, for every typ.** The build receipt, readable: what was
/// built, its sha, the settings it was built with, the edges it consumed, and what
/// the binary's own symbol table says about the claims. A lib gets this and no verb.
fn draw_buildinfo(&mut self, ui: &mut Ui) {
let Some(o) = self.report.outcome.clone() else {
return;
};
if !o.ok {
return;
}
ui.separator();
ui.label(RichText::new("buildinfo").strong());
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("kvitto").monospace().weak());
match &o.receipt {
Some(r) => {
ui.label(RichText::new(format!("typ {}", r.target)).monospace());
ui.label(RichText::new(receipt_line(r)).monospace().small());
}
None => {
ui.label(RichText::new("INGET KVITTO i artefakten").color(AMBER));
}
}
});
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("artefakt").monospace().weak());
ui.label(RichText::new(&o.artifact).monospace().small());
if let Some(s) = &o.sha {
ui.label(
RichText::new(format!("sha {}", short(s)))
.monospace()
.small(),
);
}
if let Some(n) = o.size_bytes {
ui.label(RichText::new(bytes(n)).weak().small());
}
if let Some(p) = &o.proof {
ui.label(RichText::new(format!("bevis {}", p.label())).small());
}
});
// The edges the variant consumed — the pins a re-reader needs beside the sha.
let edges: Vec<String> = self
.tree
.as_ref()
.map(|t| {
t.flags()
.into_iter()
.filter(|f| f.starts_with("--build"))
.collect()
})
.unwrap_or_default();
if !edges.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("kanter").monospace().weak());
for e in edges {
ui.label(RichText::new(e).monospace().small());
}
});
}
}
/// **The verbs the typ bears** — from [`crate::actions::actions`], the data table,
/// never a kind-branch here: RUN with its `run.where` val for an exe, RUN for a
/// container, BOOT for a medium, nothing for a lib. Firing a verb writes the RUN
/// receipt (the second receipt, law E) and hands the act to the forge; the console
/// leaf draws the surfaces.
fn draw_verbs(&mut self, ui: &mut Ui) {
let Some(typ) = self.built_typ() else { return };
let verbs: Vec<crate::actions::Verb> = self
.forge
.actions(typ)
.into_iter()
.map(Into::into)
.collect();
self.last_verbs = verbs.clone();
let a = crate::actions::with_verbs(typ, verbs);
if a.verbs.is_empty() {
ui.label(
RichText::new(format!(
"typ {} bär inga verb — bygget är klart",
typ.as_str()
))
.weak()
.small(),
);
return;
}
let mut fire: Option<crate::actions::Verb> = None;
ui.horizontal_wrapped(|ui| {
if let Some(w) = &a.where_ {
// The `where` val — the only two places a program runs from ⚒. Its path
// is on screen; the flag is honest: `run <koordinat>` on the CLI is not verified yet.
ui.label(RichText::new(w.path).monospace().weak().small());
for c in &w.choices {
if ui
.selectable_label(self.run_where == *c, RichText::new(*c).monospace())
.clicked()
{
self.run_where = (*c).to_string();
}
}
ui.label(
RichText::new("(`run <koordinat>` på CLI:t är gap #7 — ingen promptflagga än)")
.small()
.color(AMBER),
);
}
if a.pane == PaneKind::Exec {
let caption = ui.label(RichText::new("argv").monospace());
ui.add(
egui::TextEdit::singleline(&mut self.argv)
.hint_text("argument")
.desired_width(240.0),
)
.labelled_by(caption.id);
}
let live = self.report.live;
for v in &a.verbs {
if ui
.add_enabled(!live, egui::Button::new(RichText::new(v.label()).strong()))
.clicked()
{
fire = Some(*v);
}
}
});
if let Some(n) = &self.run_note {
ui.colored_label(AMBER, n);
}
let Some(v) = fire else { return };
self.run_note = None;
let where_ = a.where_.as_ref().map(|_| self.run_where.clone());
// A program in a VM is not wired yet: the val offers it because the containers
// rule names it, and the pane says so rather than running it on the host.
if where_.as_deref() == Some("vm") && a.pane == PaneKind::Exec {
self.run_note = Some(
"exe i vm: inte kopplat än — kör i container, eller vänta på vm-vägen".to_string(),
);
return;
}
self.run_receipt = Some(crate::actions::RunReceipt {
typ: typ.as_str().to_string(),
verb: v.label().to_string(),
where_,
artifact_sha: self.report.outcome.as_ref().and_then(|o| o.sha.clone()),
outcome: "running".to_string(),
});
match (v, a.pane) {
(crate::actions::Verb::Run, PaneKind::Exec) => {
let argv = self.argv.clone();
self.act(Act::Run(argv));
}
(crate::actions::Verb::Run, _) | (crate::actions::Verb::Boot, _) => {
self.act(Act::Start)
}
(crate::actions::Verb::Unpack, _) => {
self.run_note = Some(
"UNPACK: ingen typ bär det än (arkivtypen finns inte som artefakttyp)"
.to_string(),
);
}
}
}
/// The RUN receipt's outcome follows the console: `running` while the subject is
/// up, then the last measured line — an exit code, INSTALL-OK, a stop.
fn settle_run_receipt(&mut self) {
let Some(r) = self.run_receipt.as_mut() else {
return;
};
if self.report.live {
r.outcome = "running".to_string();
} else if r.outcome == "running" {
r.outcome = self
.report
.lines
.iter()
.rev()
.find(|l| !l.trim().is_empty())
.cloned()
.unwrap_or_else(|| "stopped".to_string());
}
}
/// The verbs and the where, as data — what the menu offers after a build.
fn actions_json(&self) -> Value {
match self.built_typ() {
Some(t) => {
let mut j = crate::actions::with_verbs(t, self.last_verbs.clone()).state_json();
if let Value::Object(o) = &mut j {
o.insert("run_where".into(), json!(self.run_where));
}
j
}
None => Value::Null,
}
}
/// The buildinfo, as data.
fn buildinfo_json(&self) -> Value {
match self.report.outcome.as_ref().filter(|o| o.ok) {
Some(o) => json!({
"typ": self.built_typ().map(|t| t.as_str()),
"receipt": o.receipt.as_ref().map(receipt_line),
"artifact": o.artifact,
"sha": o.sha,
"proof": o.proof.as_ref().map(|p| p.label()),
"edges": self.tree.as_ref().map(|t| t.flags().into_iter().filter(|f| f.starts_with("--build")).collect::<Vec<_>>()),
}),
None => Value::Null,
}
}
/// The facts the recipe FIXED for the picked variant — kind, profile, target,
/// version, the donor line — read-only, above the controls. Not nodes: nothing here
/// is a choice.
fn draw_fixed_facts(&mut self, ui: &mut Ui) {
let fixed: Vec<Setting> = self
.selected_target()
.map(|t| t.settings.iter().filter(|s| s.fixed).cloned().collect())
.unwrap_or_default();
for s in &fixed {
ui.horizontal(|ui| {
ui.label(RichText::new(&s.key).monospace());
ui.label(RichText::new(&s.value).monospace().weak());
if let Some(n) = &s.note {
ui.label(RichText::new(n).weak().small());
}
});
}
}
/// **One node's control**, its path and its flag on the same line — and, for a node
/// with children, the subgraph named (opened in the graph, not unrolled here).
fn draw_control(&mut self, ui: &mut Ui, n: &NodeView, depth: usize, variant: &str) {
let is_kernel = n.path == KERNEL_PATH;
ui.horizontal(|ui| {
ui.add_space(depth as f32 * 18.0);
match n.kind {
"feature" if is_kernel => {
ui.label(RichText::new(&n.label).strong());
if let Some(e) = &n.edge {
ui.label(RichText::new(format!("→ {e}")).monospace());
self.draw_edge_verb(ui, n, variant);
}
}
"feature" if n.edge.is_some() => {
// Any node with a consumption edge (wave 2's typed edge) bears BYGG
// for its edge target — the same road as the kärna node's.
ui.label(RichText::new(&n.label).strong());
ui.label(RichText::new(format!("→ {}", n.edge.as_deref().unwrap_or_default())).monospace());
self.draw_edge_verb(ui, n, variant);
}
"feature" if n.symbol => {
// Through the tree's own judge — cascade and contract lock included —
// the same door the CLI's `--opt kernel.<symbol>=off` goes through.
let mut on = n.is_on();
let cb = ui.checkbox(&mut on, RichText::new(&n.label).monospace());
if !n.locked_by.is_empty() {
cb.on_hover_text(format!("LÅST — bryter {}", n.locked_by.join(", ")));
}
if on != n.is_on() {
self.toggle_symbol(variant, &n.path, on);
}
}
"feature" if n.fixed => {
ui.label(RichText::new(&n.label).monospace());
ui.label(RichText::new("på").monospace().weak());
}
"feature" => {
let cur = self.chosen.entry(n.key.clone()).or_insert_with(|| n.value.clone());
let mut on = cur == "true";
let cb = ui.checkbox(&mut on, RichText::new(&n.label).strong());
if let Some(f) = &n.feature {
cb.on_hover_text(format!(
"av ⇒ {f} byggs INTE in i den packade binären (--no-default-features utan {f}), \
inte bara avstängd vid körning"
));
// The cost line rides only on a feature the prompt can name (an
// opt-out); an open cargo feature already says it reaches nothing.
if n.flag.is_some() {
ui.label(
RichText::new(format!("{}{f}", if on { '+' } else { '\u{2212}' }))
.monospace()
.weak()
.small(),
);
}
}
if on != (cur == "true") {
*cur = on.to_string();
}
}
"val" => {
ui.label(RichText::new(&n.label).monospace());
let cur = self.chosen.entry(n.key.clone()).or_insert_with(|| n.value.clone());
let mut want: Option<String> = None;
for c in &n.choices {
if ui.selectable_label(cur == c, RichText::new(c).monospace()).clicked() {
want = Some(c.clone());
}
}
if let Some(v) = want {
*cur = v;
}
}
_ => {
// The KEY is the caption a field is labelled by — the name a robot types
// into and the alias the prompt accepts; the declaration's own label
// rides beside it.
let caption = ui.label(RichText::new(&n.key).monospace());
if n.label != n.key {
ui.label(RichText::new(&n.label).weak().small());
}
let cur = self.chosen.entry(n.key.clone()).or_insert_with(|| n.value.clone());
draw_param_field(ui, &n.key, n.declared.as_ref(), cur, caption.id);
}
}
// ── LAW C, ON SCREEN: the path, and the flag it equals ───────────────
// The path is drawn when it says more than the control's own caption — a
// root's path IS its name, and two atoms with one name make both unreachable.
if n.path != n.key && n.path != n.label {
ui.label(RichText::new(&n.path).monospace().weak().small());
}
if let Some(f) = &n.flag {
ui.label(RichText::new(f).monospace().small());
}
if !n.children.is_empty() && depth > 0 {
ui.label(RichText::new(format!("▸ delgraf · {} noder", n.children.len())).weak().small());
}
});
// The node's note goes BELOW its row, wrapping: inside the row it was one
// line of a horizontal layout and got CLIPPED ("— klicket n", on the
// owner's screen 2026-10-16).
if let (None, Some(why)) = (&n.flag, &n.no_flag_why) {
node_note(ui, why, depth);
}
// A root's children with children are subgraphs in the graph; its leaf
// children draw here so a variant's whole flat surface is one menu. Under
// `kärna` only the VALS draw here (preset, profile — the choices); the symbol
// features are the subgraph, reached by selecting the node.
if depth == 0 {
let on = n.is_on();
for c in n.children.iter().filter(|c| {
c.when.is_none_or(|w| w == on)
&& c.children.is_empty()
&& (!is_kernel || c.kind == "val")
}) {
self.draw_control(ui, c, 1, variant);
}
}
}
/// **A `kernel.<symbol>` click, judged.** Through the model crate's `SettingsTree::try_toggle`
/// on the picked variant's tree — cascade and contract lock included — the SAME
/// door the CLI's `--opt kernel.<symbol>=off` goes through. An accepted decision
/// lands in [`Self::chosen`] under the full path (so `Act::Build` carries
/// `kernel.<symbol>=off` and the flag on screen is the flag the door gets), its
/// cascade with it; a refusal is shown, verbatim, and turns nothing.
fn toggle_symbol(&mut self, variant: &str, path: &str, on: bool) {
// No tree for the picked row/target: nothing to judge the click against.
if self
.forge
.tree(
self.selected_row().map(|r| r.name.clone()).as_deref(),
self.selected_target().map(|t| t.name.clone()).as_deref(),
&self.chosen,
)
.is_none()
{
return;
}
// ONE spelling, the model crate's (lane AK, 2026-08-30). A kernel VARIANT's
// roots used to be BARE in the settings tree (`preset`, `virtio-net`) while the
// view, the graph and the prompt all spelled them `kernel.*`, so this pane
// carried a spell/unspell layer to cross between them on every click.
// `SettingsTree::of_kernel` scopes the row itself now — the same spelling the
// CLI accepts and `--list-options` prints — so the crossing is gone and the
// path the pane holds IS the path the judge resolves.
let vals: [String; 2] = [
format!("{KERNEL_PATH}{PATH_SEP}preset"),
format!("{KERNEL_PATH}{PATH_SEP}profile"),
];
let mut req = ToggleRequest::new(variant, path, on);
for (k, v) in &self.chosen {
if k.starts_with(&format!("{KERNEL_PATH}.")) && !vals.contains(k) {
req.state.insert(k.clone(), v == "true");
}
}
for p in &vals {
// A choice recorded before the scope existed (or by a door that spells a
// val bare) is still honoured, under the tree's spelling.
let bare = p.rsplit(PATH_SEP).next().unwrap_or(p);
if let Some(v) = self.chosen.get(p).or_else(|| self.chosen.get(bare)) {
req.vals.insert(p.clone(), v.clone());
}
}
// The judge is the forge's: the build thing over wasm when loaded, the derived
// tree otherwise — never a second judge in the pane.
let d = self.forge.try_toggle(&req);
if !d.verdict.ok {
self.menu_note = Some(d.verdict.line());
return;
}
self.menu_note = None;
self.chosen.insert(d.path.clone(), on.to_string());
for c in &d.cascade {
self.chosen.insert(c.clone(), "false".to_string());
}
}
/// The result header and the artifact/receipt lines the sketches show.
fn draw_result_head(&mut self, ui: &mut Ui) {
let (Some(row), Some(target)) = (self.selected_row(), self.selected_target()) else {
return;
};
let Some(phase) = self.report.phase.clone() else {
return;
};
let head = format!("{} · {}", row.name, target.name);
let secs = self.report.elapsed_ms as f64 / 1000.0;
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(RichText::new(head).strong().monospace());
let colour = match &phase {
Phase::Failed(_) => RED,
Phase::Done => TEAL,
_ => AMBER,
};
ui.label(RichText::new(format!("{} {secs:.1}s", phase.label())).color(colour));
});
if let Phase::Failed(why) = &phase {
ui.colored_label(RED, why);
}
if let Some(o) = &self.report.outcome {
ui.horizontal(|ui| {
ui.label(RichText::new("artefakt").weak());
// The path is the way to the FILE, so it has to leave the pane:
// click it (or the button) and it lands on the clipboard, through
// the shared clip seam rather than a second copy path.
// Showing a path the operator can only read is what made an ISO
// that had been built for hours feel unreachable.
let on_disk = artifact_on_disk(&o.artifact);
let text = RichText::new(&o.artifact).monospace();
let text = if on_disk.is_some() {
text
} else {
text.color(RED)
};
let hit = ui
.add(egui::Label::new(text).sense(egui::Sense::click()))
.on_hover_text(match &on_disk {
Some(m) => format!(
"klicka för att kopiera sökvägen\n{}\n{} · byggd {}",
o.artifact,
bytes(m.0),
m.1
),
None => format!(
"{}\nFILEN FINNS INTE PÅ DISK just nu — utfallet är från en \
tidigare körning, eller så städades filen bort",
o.artifact
),
});
if hit.clicked() {
put_typed(ui.ctx(), ClipPayload::Text(o.artifact.clone()));
}
if ui
.small_button("📋")
.on_hover_text("kopiera artefaktens sökväg")
.clicked()
{
put_typed(ui.ctx(), ClipPayload::Text(o.artifact.clone()));
}
if let Some(dir) = std::path::Path::new(&o.artifact).parent() {
let dir = dir.display().to_string();
if ui
.small_button("📁")
.on_hover_text(format!("kopiera katalogen\n{dir}"))
.clicked()
{
put_typed(ui.ctx(), ClipPayload::Text(dir));
}
}
if on_disk.is_none() {
ui.label(RichText::new("saknas på disk").color(RED));
}
if let Some(b) = o.size_bytes {
ui.label(RichText::new(bytes(b)).monospace().weak());
}
if let Some(s) = &o.sha {
ui.label(RichText::new(short(s)).monospace().weak());
}
if let Some(n) = &o.note {
ui.label(RichText::new(n).weak());
}
});
ui.horizontal(|ui| {
ui.label(RichText::new("kvitto ").weak());
match &o.receipt {
Some(r) => ui.label(RichText::new(receipt_line(r)).monospace()),
None => {
ui.label(RichText::new("inget kvitto skrivet i artefakten").color(AMBER))
}
};
});
// The per-site record the build APPLIED — the forge's own sentence per
// value, so a fixture-filled one reads "← fixture wg-hub (…)" here, in
// the CLI log, and in the attested descriptor alike.
for line in &o.applied {
ui.horizontal(|ui| {
ui.label(RichText::new("param ").weak());
ui.label(RichText::new(line).monospace());
});
}
// Which cargo features the artifact was built with — the record's own.
if let Some(f) = &o.features {
ui.add(egui::Label::new(RichText::new(f).monospace()).wrap());
}
// ── WHERE THE RECORD LANDED ──────────────────────────────────────
//
// ALWAYS drawn once there is an outcome, never only on failure: a row
// that appears when something is wrong is a row nobody learns to read,
// and "filed nowhere" has to be as visible as "filed". Three colours
// for the three states the writer already tells apart — RED for a
// configured server that took nothing, TEAL for rows that landed,
// AMBER for a build that never tried. Never green-by-default, and
// never absent.
{
let colour = if o.warehouse_red {
RED
} else if o.warehouse_landed {
TEAL
} else {
AMBER
};
ui.horizontal(|ui| {
ui.label(RichText::new("register").weak());
ui.label(RichText::new(&o.warehouse_line).color(colour).monospace());
});
}
// The third level. Shown whenever there was an artifact to measure,
// including when the measurement could not be made: "not measured" is
// an outcome an operator has to see, not a row that disappears.
if let Some(p) = &o.proof {
let colour = match p.verdict {
ReceiptVerdict::Proven => TEAL,
ReceiptVerdict::Disproven => RED,
_ => AMBER,
};
ui.horizontal(|ui| {
ui.label(RichText::new("bevis ").weak());
ui.label(RichText::new(p.label()).color(colour).monospace().strong());
});
for line in &p.lines {
ui.label(RichText::new(line).color(colour).monospace().weak());
}
}
}
}
/// **The pane below.** One `match`, three arms, and no name of anything.
fn draw_pane(&mut self, ui: &mut Ui) {
if self.report.outcome.is_none() {
return;
}
let Some(kind) = self.pane() else { return };
ui.add_space(4.0);
match kind {
PaneKind::None => {
ui.label(RichText::new("den här artefakten går inte att köra").weak());
}
PaneKind::Exec => self.draw_exec(ui),
PaneKind::Log => self.draw_log(ui),
PaneKind::Console => self.draw_console(ui),
}
if let Some(n) = &self.report.note {
ui.colored_label(AMBER, n);
}
}
/// [`PaneKind::Exec`] — an argv field and stdout. What an executable offers.
fn draw_exec(&mut self, ui: &mut Ui) {
let mut run = false;
ui.horizontal(|ui| {
let caption = ui.label(RichText::new("argv").monospace());
ui.add(
egui::TextEdit::singleline(&mut self.argv)
.hint_text("argument")
.desired_width(360.0),
)
.labelled_by(caption.id);
run = ui.button("KÖR").clicked();
});
if run {
let argv = self.argv.clone();
self.act(Act::Run(argv));
}
self.draw_lines(ui, "stdout");
}
/// [`PaneKind::Log`] — start, stop, the log, and the ports the recipe declared.
fn draw_log(&mut self, ui: &mut Ui) {
let live = self.report.live;
let ports = self
.report
.outcome
.as_ref()
.map(|o| o.ports.clone())
.unwrap_or_default();
let mut act = None;
ui.horizontal(|ui| {
if ui
.add_enabled(!live, egui::Button::new("▶ START"))
.clicked()
{
act = Some(Act::Start);
}
if ui.add_enabled(live, egui::Button::new("✕ STOPP")).clicked() {
act = Some(Act::Stop);
}
for p in &ports {
ui.label(RichText::new(format!(":{p}")).monospace().color(TEAL));
}
});
if let Some(a) = act {
self.act(a);
}
self.draw_lines(ui, "logg");
}
/// [`PaneKind::Console`] — run, reboot, stop, and BOTH consoles, one above the other.
///
/// The serial and the framebuffer are not two modes of one view; they answer two
/// different questions about the same guest and an installation needs both answers
/// at the same time. The same pane serves every bootable kind.
fn draw_console(&mut self, ui: &mut Ui) {
let live = self.report.live;
let mut act = None;
ui.horizontal(|ui| {
if ui.add_enabled(!live, egui::Button::new("▶ KÖR")).clicked() {
act = Some(Act::Start);
}
if ui
.add_enabled(live, egui::Button::new("↻ BOOTA OM"))
.clicked()
{
act = Some(Act::Reboot);
}
if ui.add_enabled(live, egui::Button::new("✕ STOPP")).clicked() {
act = Some(Act::Stop);
}
});
if let Some(a) = act {
self.act(a);
}
self.draw_lines(ui, "serial");
ui.label(RichText::new("grafisk").weak().small());
egui::Frame::group(ui.style()).show(ui, |ui| self.draw_screen(ui));
}
/// The text console the three panes share.
///
/// ── WHY THIS IS ONE WIDGET AND NOT N LABELS ──────────────────────────────
///
/// **MEASURED live by the owner 2026-08-20** (stinky `kopiera-1`): Ctrl+C over this
/// block pasted the PREVIOUS clipboard contents — intermittently, and reliably
/// only while a build was actually running.
///
/// It was `N` separate `ui.label`s in this scroll area. egui 0.35 holds a
/// cross-label selection in ONE context-wide plugin, and
/// `label_text_selection.rs:220-224` says verbatim "let's just deselect" the moment
/// either end of the selection leaves the visible rect. `stick_to_bottom(true)` plus
/// the 120 ms `WORKING_REPAINT` above meant the viewport was yanked on every new
/// line, so the selection was dropped, `text_to_copy` was emptied, and `copy_text`
/// was never called at all — leaving the clipboard holding whatever it had before.
/// That is why the symptom read as "copied the WRONG text" and not "copied nothing",
/// and why it went away as soon as the run settled and stopped scrolling.
///
/// [`CopyableBlock`] is the shared cure (LAW 5 — the same widget
/// serves every log/console block in the constellation): ONE read-only
/// `TextEdit::multiline`, whose selection lives in its own `TextEditState` that
/// `LabelSelectionState` never touches, plus an explicit button that hands the whole
/// block over in a call no viewport movement can interrupt. `follow` keeps the
/// newest line in view but yields while the pointer is down.
///
/// The scroll area is salted per `label` too: this used to be the one `ScrollArea`
/// in the pane with no `id_salt` at all, so `stdout`, `logg` and `serial` shared a
/// scroll offset (stinky `kopiera-3`).
fn draw_lines(&mut self, ui: &mut Ui, label: &str) {
ui.label(RichText::new(label).weak().small());
egui::Frame::group(ui.style()).show(ui, |ui| {
if self.report.lines.is_empty() {
ui.label(RichText::new("(inget ännu)").weak());
return;
}
let text = CopyableBlock::join(&self.report.lines);
CopyableBlock::new(label, &text)
.max_height(220.0)
.follow(true)
.show(ui);
});
}
/// The graphical console. A console with no frame yet SAYS so — it never draws a
/// placeholder that could be mistaken for the guest's screen.
fn draw_screen(&mut self, ui: &mut Ui) {
let Some(s) = self.report.screen.clone() else {
ui.colored_label(AMBER, "ingen bildruta ännu");
return;
};
if s.rgba.len() != s.width * s.height * 4 {
ui.colored_label(RED, "bildrutan har fel storlek och ritas inte");
return;
}
let image = egui::ColorImage::from_rgba_unmultiplied([s.width, s.height], &s.rgba);
let tex = self.texture.get_or_insert_with(|| {
// NEAREST, not the default linear: this is a guest CONSOLE, and upscaling
// text with a linear filter turns crisp glyphs into grey mush. A 2x nearest
// upscale of an 80x25 VGA console is readable; a 2x linear one is not.
ui.ctx().load_texture(
"build-scene-console",
image.clone(),
egui::TextureOptions::NEAREST,
)
});
tex.set(image, egui::TextureOptions::NEAREST);
let size = tex.size_vec2();
let avail = ui.available_size();
ui.image((tex.id(), size * console_scale(size, avail)));
}
}
/// **How big the guest's console is drawn** — a pure function so the rule can be proved
/// without a GPU, a window or a running VM.
///
/// It used to be `(available_width / width).min(1.0)`, and that `.min(1.0)` is the whole
/// defect: it caps the scale at NATIVE SIZE, so the console can only ever be shrunk. A
/// guest that boots at 800x600 into a 1600-pixel-wide pane drew at 800x600 with half the
/// pane empty beside it — the owner's *"that window has to be made bigger"*. Nothing about
/// a framebuffer makes its own pixel count the right size on someone else's display.
///
/// The rule now: fill the space, keep the aspect, and be bounded by BOTH axes. Bounding
/// on width alone is why a cap felt necessary in the first place — an upscale that only
/// watches width will run a 4:3 console straight off the bottom of a wide pane, and the
/// operator loses the part they were reading.
///
/// [`MAX_CONSOLE_SCALE`] stops a tiny frame from being blown into abstract art: an early
/// boot frame can legitimately be a few hundred pixels, and 8x of that is a wall of
/// squares, not a console. Degenerate inputs (a zero-sized texture, a pane with no room)
/// fall back to 1.0 rather than producing an infinity or a zero, because a console drawn
/// at native size is a recoverable mistake and one drawn at NaN is a panic.
fn console_scale(size: egui::Vec2, avail: egui::Vec2) -> f32 {
if size.x <= 0.0 || size.y <= 0.0 || avail.x <= 0.0 || avail.y <= 0.0 {
return 1.0;
}
(avail.x / size.x)
.min(avail.y / size.y)
.min(MAX_CONSOLE_SCALE)
}
/// The ceiling on [`console_scale`]. Chosen so a 320x200 early-boot frame reaches a
/// legible ~1280x800 and stops there, rather than being stretched until the pixels are
/// the size of the text they are made of.
const MAX_CONSOLE_SCALE: f32 = 4.0;
// ── the facet contract ────────────────────────────────────────────────────────
impl Facet for BuildScene {
fn title(&self) -> &str {
"⚒ Build Thing"
}
fn ui(&mut self, ui: &mut Ui) {
self.begin_pass(ui);
self.draw_entry(ui);
self.draw_graph(ui, false);
self.draw_menu(ui);
self.draw_steps(ui);
self.draw_result_head(ui);
self.draw_pane(ui);
self.draw_graph_canvas(ui);
}
fn component(&self) -> &'static str {
COMPONENT
}
fn severity(&self) -> Severity {
match (&self.error, &self.report.phase) {
(Some(_), _) | (_, Some(Phase::Failed(_))) => Severity::Error,
(None, None) if self.menu.is_none() => Severity::Warning,
_ => Severity::Info,
}
}
fn state_json(&self) -> Value {
let rows: Vec<Value> = self
.menu
.iter()
.flat_map(|m| m.rows.iter())
.map(|r| {
json!({
"name": r.name,
"badges": r.badges,
"targets": r.targets.iter().map(|t| json!({
"name": t.name,
"kind": t.kind.as_str(),
"pane": t.pane(),
"declared": t.declared,
"settings": t.settings.iter().map(|s| json!({
"key": s.key, "value": s.value, "fixed": s.fixed,
// The closed vocabulary, when there is one. `[]` on
// every free-text setting — a driver that wants to
// know whether a control can be TURNED asks here.
"choices": s.choices.iter().map(|c| c.value.clone()).collect::<Vec<_>>(),
"fixture": s.declared.as_ref().and_then(|d| d.fixture.clone()),
"multiline": s.declared.as_ref().is_some_and(|d| d.multiline),
// The opt-out axis: what this setting IS, and what
// it answers to. Null on every setting that is
// neither, which is every setting a pre-options
// recipe produces.
"option": s.declared_option.as_ref().map(|o| json!({
"label": o.label, "feature": o.feature,
})),
"governed_by": s.governed.as_ref().map(|g| json!({
"option": g.option, "shown_when": g.shown_when,
})),
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
})
.collect();
json!({
"title": self.title(),
"component": COMPONENT,
"artifact": self.artifact,
"coordinate_field": self.coordinate,
"recipe": self.menu.as_ref().map(|m| m.coordinate.to_string()),
"how": self.menu.as_ref().map(|m| m.how.clone()),
"error": self.error,
"rows": rows,
"row": self.selected_row().map(|r| r.name.clone()),
"target": self.selected_target().map(|t| t.name.clone()),
"pane": self.pane(),
"can_build": self.can_build(),
// WHY it is shut, so a driver asserts on the reason rather than on
// the bare `false` a dozen different faults all produce.
"unmet": self.unmet_inputs()
.into_iter()
.map(|(k, why)| json!({"key": k, "why": why}))
.collect::<Vec<_>>(),
// The opt-outs as they stand, and the cargo-feature delta they
// resolve to. `feature_delta` is the PREVIEW, in the same words the
// receipt uses once the forge has answered.
"toggles": self.toggles(),
"feature_delta": self.feature_delta(),
// Which inputs the toggles currently ask for — the list the operator
// is actually looking at, so a hidden field cannot be asserted as
// present.
"visible_settings": self.selected_target().map(|t| {
let toggles = self.toggles();
t.settings
.iter()
.filter(|s| s.visible_in(&toggles))
.map(|s| s.key.clone())
.collect::<Vec<_>>()
}),
// The dependency picture, whole. Everything a driver can assert about
// BOTH directions is in the pane's own state — there is no second copy
// here that could tell a different story than the graph on screen.
"axis": self.galois.as_ref().map(GaloisPane::state_json),
// What the picture on screen is a picture OF — row, column and the
// toggles that were in force when it was derived. A driver asserts
// on THIS to know the graph followed a tick, rather than on the
// graph's own contents alone.
"axis_row": self.axis_row.as_ref().map(|p| p.row.clone()),
"axis_pick": self.axis_row.as_ref().map(|p| json!({
"row": p.row, "target": p.target, "toggles": p.toggles,
})),
"axis_note": self.axis_note,
// WHICH ROAD the picture came down, as three distinguishable words plus
// the warehouse's own excuse. A driver asserts on this rather than on a
// substring of the provenance sentence — the whole point of the split is
// that a fallback must not be mistakable for a warehouse read, and a test
// that greps prose is one rewording away from asserting nothing.
"axis_road": self.axis_road.as_ref().map(|r| match r {
AxisRoad::Warehouse { .. } => "warehouse",
AxisRoad::Manifests { .. } => "manifests",
AxisRoad::Unlocated { .. } => "unlocated",
}),
// The crates the graph is NOT showing and why a driver may click them.
// Both halves are observable because both halves are claims: the list is
// "we have not looked at these", and the focus set is "these were asked
// for". A test that could only see the graph could not tell a crate that
// was never hunted from one hunted and found absent.
"axis_unanalysed": self.axis_unanalysed,
"axis_focus": self.axis_focus.iter().collect::<Vec<_>>(),
"axis_road_detail": self.axis_road.as_ref().map(|r| match r {
AxisRoad::Warehouse { path } => path.clone(),
AxisRoad::Manifests { why } | AxisRoad::Unlocated { why } => why.clone(),
}),
// The forge's own leaf, whole: its facet state when it is up, its
// refusal when it is not, and null when this selection has no such
// surface. A driver asserts on THIS rather than on a widget it hopes
// is somewhere below the settings block.
"leaf": self.leaf,
// The picked variant's TREE as the graph and the menu draw it: every node with
// its path and the flag it equals (law C), the `kärna` node where the variant
// has one. Null with no recipe resolved.
"tree": self.tree.as_ref().map(TreeView::state_json),
// The build graph: what is selected, what is folded, how big.
"graph": self.graph_json(),
// The selected node's menu: its subject and its controls with their flags.
"menu": self.menu_json(),
// The second receipt: what happened when it ran.
"run_receipt": self.run_receipt.as_ref().map(crate::actions::RunReceipt::state_json),
// The step console: the edge DAG the last `BYGG <kant>` planned, every step's
// state word, the clock — and its receipt, beside the other two.
"steps": self.steps.state_json(),
"edge_build_receipt": self.steps.receipt().map(crate::build_steps::EdgeBuildReceipt::state_json),
"settings": self.chosen,
"argv": self.argv,
// Every console this pane is showing, at once. A toggle would put one
// string here; the design puts both.
"consoles": if self.pane() == Some(PaneKind::Console) { CONSOLES.to_vec() } else { Vec::new() },
"phase": self.report.phase.as_ref().map(Phase::label),
"failed": matches!(self.report.phase, Some(Phase::Failed(_))),
"elapsed_ms": self.report.elapsed_ms,
"live": self.report.live,
"lines": self.report.lines,
"line_count": self.report.lines.len(),
"note": self.report.note,
"screen": self.report.screen.as_ref().map(|s| json!({
"width": s.width, "height": s.height, "bytes": s.rgba.len(),
})),
"outcome": self.report.outcome.as_ref().map(|o| json!({
"ok": o.ok,
"artifact": o.artifact,
// Whether the named file is THERE, asked of the filesystem at read
// time — an Outcome outlives its artifact, and a path to nothing
// must not read as a delivered file.
"artifact_on_disk": artifact_on_disk(&o.artifact).is_some(),
"artifact_dir": std::path::Path::new(&o.artifact)
.parent()
.map(|p| p.display().to_string()),
"size_bytes": o.size_bytes,
"sha": o.sha,
"ports": o.ports,
"has_receipt": o.receipt.is_some(),
"receipt": o.receipt.as_ref().map(receipt_line),
"applied": o.applied,
"features": o.features,
// `proven` is deliberately not `!failed`: NotMeasured is false
// here and must stay false, or a stripped binary reads as a pass.
"proof": o.proof.as_ref().map(|p| p.label()),
"proven": o.proof.as_ref().is_some_and(Proof::holds),
"proof_lines": o.proof.as_ref().map(|p| p.lines.clone()).unwrap_or_default(),
// Where the provenance record landed. `landed` is NOT `!red` —
// a build that never tried is false in both, and a driver that
// asserted on one alone could not tell it from a success.
"warehouse_line": o.warehouse_line,
"warehouse_red": o.warehouse_red,
"warehouse_landed": o.warehouse_landed,
})),
})
}
/// The headless seam. One message per step of the flow, named after the step.
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<Value>(msg_json) else {
return;
};
if let Some(s) = v.get("artifact").and_then(Value::as_str) {
self.artifact = s.to_string();
}
if let Some(s) = v.get("coordinate").and_then(Value::as_str) {
self.coordinate = s.to_string();
}
if let Some(s) = v.get("argv").and_then(Value::as_str) {
self.argv = s.to_string();
}
if let Some(s) = v.get("set_setting").and_then(Value::as_object) {
for (k, val) in s {
if let Some(val) = val.as_str() {
self.chosen.insert(k.clone(), val.to_string());
}
}
}
if v.get("resolve").is_some() {
self.resolve_artifact();
}
if v.get("resolve_coordinate").is_some() {
self.resolve_coordinate();
}
if let Some(name) = v.get("pick_row").and_then(Value::as_str) {
if let Some(i) = self
.menu
.as_ref()
.and_then(|m| m.rows.iter().position(|r| r.name == name))
{
self.pick_row(i);
}
}
if let Some(name) = v.get("pick_target").and_then(Value::as_str) {
if let Some(i) = self
.selected_row()
.and_then(|r| r.targets.iter().position(|t| t.name == name))
{
self.pick_target(i);
}
}
// The two directions, through the SAME entry point a pointer click takes:
// `feat:<name>` for a feature, `crate:<name>` for a package, `and:…` for a
// conjunction. A message per direction would be two code paths for one
// model, which is the drift this pane exists to not have.
// Buy one crate's minimal-cause hunt — the same entry point the chip's
// pointer click takes, so a driver exercises the shipped path and not a
// parallel one.
if let Some(krate) = v.get("focus_crate").and_then(Value::as_str) {
self.focus_crate(krate);
}
if let Some(node) = v.get("click_axis").and_then(Value::as_str) {
let node = node.to_string();
self.click_axis(&node);
}
if v.get("build").is_some() {
self.build();
}
if v.get("run").is_some() {
let argv = self.argv.clone();
self.act(Act::Run(argv));
}
if v.get("start").is_some() {
self.act(Act::Start);
}
if v.get("stop").is_some() {
self.act(Act::Stop);
}
if v.get("reboot").is_some() {
self.act(Act::Reboot);
}
// `BYGG <kant>` by node path — the button's headless twin.
if let Some(path) = v.get("bygg_edge").and_then(Value::as_str) {
let path = path.to_string();
if !self.bygg_edge_at(&path) {
self.menu_note = Some(format!(
"`{path}` bär ingen konsumtionskant — inget BYGG att fyra av"
));
}
}
// The dry pass by node path — plans the chain, spawns nothing.
if let Some(path) = v.get("plan_edge").and_then(Value::as_str) {
let path = path.to_string();
if !self.plan_edge_at(&path) {
self.menu_note = Some(format!(
"`{path}` bär ingen konsumtionskant — ingen kedja att planera"
));
}
}
// A step plan as data — the fixture door for the console's five states.
if let Some(plan) = v.get("stage_steps") {
if let Err(e) = self.stage_steps(plan) {
self.menu_note = Some(format!("stage_steps: {e}"));
}
}
if v.get("poll").is_some() {
self.take_report();
}
}
}
/// **The ⚒ body DECOMPOSES into five named leaves** — the tree
/// [`crate::build_thing_scene::body`] has declared since it was written, and which had
/// no caller until this impl existed.
///
/// It is a DECOMPOSITION, not a rewrite: every method here is one of the same
/// `draw_*` calls `Facet::ui` makes, in the same order, so a harness that boxes this
/// pane whole draws exactly what it drew before. What changes is that the five regions
/// are now five panes with four draggable dividers between them, which is the whole
/// point — *"fönstret går inte att förstora"* had a precise cause, and it was that the
/// body was one `Slot` with no divider anywhere inside it.
impl crate::tabbed::LeafPanel for BuildScene {
// No `leaf_absent` override: every ⚒ leaf has a subject whenever the tab does. The
// kernel used to be the one conditional leaf, gated through here on
// `Forge::kernel_declared`; it is the `kärna` NODE of the settings tree now (gap #6),
// and a node needs no presence overlay — it is in the tree or it is not.
fn ui_leaf(&mut self, leaf: &str, ui: &mut Ui) -> Value {
use crate::build_thing_scene as tree;
// Once a frame, however many leaves draw — see `begin_pass`.
self.begin_pass(ui);
match leaf {
// WHICH ROOT (the two doors: an artifact name, or a coordinate), then the
// build graph, then the accessible strips. Picking here re-resolves
// everything to the right of the first divider.
tree::GRAPH => {
self.draw_entry(ui);
self.draw_graph(ui, true);
json!({
"artifact": self.artifact,
"recipe": self.menu.as_ref().map(|m| m.coordinate.to_string()),
"error": self.error,
"rows": self.menu.as_ref().map(|m| m.rows.len()).unwrap_or(0),
"row": self.selected_row().map(|r| r.name.clone()),
"target": self.selected_target().map(|t| t.name.clone()),
"graph": self.graph_json(),
})
}
// The selected node's menu: its settings with their flags, the picture, ▶.
tree::MENU => {
self.draw_menu(ui);
json!({
"menu": self.menu_json(),
"tree": self.tree.as_ref().map(TreeView::state_json),
"can_build": self.can_build(),
"unmet": self.unmet_inputs()
.into_iter()
.map(|(k, why)| json!({"key": k, "why": why}))
.collect::<Vec<_>>(),
"feature_delta": self.feature_delta(),
})
}
// The step log and the guest's framebuffer.
tree::CONSOLE => {
// The step console first: the DAG a `BYGG <kant>` planned, before and
// while it runs; then the build's head and the run surfaces.
self.draw_steps(ui);
self.draw_result_head(ui);
self.draw_pane(ui);
json!({
"phase": self.report.phase.as_ref().map(Phase::label),
"pane": self.pane(),
"live": self.report.live,
"steps": self.steps.state_json(),
})
}
// A name nothing declares. SAY it — a leaf that renders nothing because the
// host asked for a pane that does not exist reads exactly like a leaf whose
// content happens to be empty, and the operator cannot tell them apart.
other => {
ui.colored_label(RED, format!("⚒ deklarerar inget blad som heter `{other}`"));
json!({ "unknown_leaf": other })
}
}
}
}
// ── small shared render bits ──────────────────────────────────────────────────
/// The tallest the ⚒ variant strip grows before it scrolls (about twelve rows).
const ROWS_STRIP_MAX_H: f32 = 280.0;
/// An edge as the view spells it — `<repo>/<variant> : <typ>` — split back into the
/// coordinate and the typ. A string without the ` : ` is the coordinate alone.
fn split_edge(edge: &str) -> (String, ArtifactKind) {
let e = Edge::from_spelled(edge);
(e.coordinate, e.typ)
}
/// One menu control as data: the node's path, kind, value and the flag it equals.
fn control_json(n: &NodeView) -> Value {
json!({
"path": n.path,
"key": n.key,
"kind": n.kind,
"value": n.value,
"flag": n.flag,
"no_flag_why": n.no_flag_why,
"edge": n.edge,
"children": n.children.len(),
})
}
/// **A declared parameter's field** — the fixture affordance ONLY where the recipe
/// declares one, multiline where the declaration stages a file, a single line
/// otherwise. The checkbox carries the key in its own label so a robot (and a screen
/// reader) can address THIS parameter's toggle; ticking it supplies the CLI's own
/// `@fixture` word, and the editor yields to one line saying so — the operator is never
/// shown a field that looks typeable but is about to be overwritten.
fn draw_param_field(
ui: &mut Ui,
key: &str,
declared: Option<&DeclaredParam>,
cur: &mut String,
caption: egui::Id,
) {
if let Some(fx) = declared.and_then(|d| d.fixture.as_deref()) {
let mut use_fixture = cur.as_str() == FIXTURE_VALUE;
if ui
.checkbox(&mut use_fixture, RichText::new(format!("{key}: använd fixtur ({fx})")).small())
.on_hover_text(
"smedjan präglar fixturen själv vid bygget — värdet blir fixturens offentliga del, \
och kvittot säger att det kom därifrån",
)
.changed()
{
*cur = if use_fixture { FIXTURE_VALUE.to_string() } else { String::new() };
}
if use_fixture {
ui.label(
RichText::new(format!(
"{FIXTURE_VALUE} → fylls av fixturen {fx} vid bygget"
))
.monospace()
.weak(),
);
return;
}
}
if declared.is_some_and(|d| d.multiline) {
ui.add(
The file goes on; the console shows all of it.