rust_widgets 2.7.0

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! BLUE20 layer 3 — export every control as an SVG snapshot.
//!
//! # What this is for
//!
//! Layers 1 and 2 are **machine** judgements: they can assert "this control painted
//! something", "its chrome moved with the theme", "this declared property is answered".
//! None of them can say "this control looks wrong". A misplaced label, a fill that hides
//! its own border, an icon that reads as a smudge — all of those satisfy every assertion
//! and are only visible to a person looking at the picture.
//!
//! So this writes one SVG per control to `snapshots/svg/`, named by `canonical_name`:
//! the 188 files become a **reviewable artifact** rather than a number. Because they are
//! committed and regenerable, any later visual change shows up as a diff.
//!
//! # Why the geometry is fixed rather than per-control
//!
//! A size chosen per control would make each image pretty and cross-control comparison
//! impossible. A single canvas (240x120, the same `CENSUS_RECT` the census measures over)
//! means two PNGs of two controls can be laid side by side and judged against each other,
//! and it makes "does this control fill its box" a question with a visible answer.
//!
//! # Why two appearances, and why some controls have a third
//!
//! `<name>.svg` is the dark appearance (the demo's default) and `<name>.light.svg` is the
//! light one. The pair is what makes "does this control respond to the theme" checkable by
//! eye rather than only by the P3 assertion: a control whose two files are identical is
//! theme-blind in a way a person can *see*, and `tools/check_svg_snapshots.sh` requires
//! both files to exist so one cannot be quietly dropped.
//!
//! A few controls have a **state** that no static appearance can show and that a user can put
//! them into: `group_box`'s tick is the one part of it a user toggles, and its `checkable` flag
//! defaults to `false`, so an unchecked `group_box.svg` is the only picture the pair carries.
//! BLUE23 §4.1 asks for that state to enter the snapshot set as `group_box_checked` — a third
//! file, produced by [`EXTRA_APPEARANCES`], so the tick is reviewable rather than merely present
//! in the source. Extras are declared here rather than invented per control so the set is one
//! list, and `tools/check_svg_snapshots.sh` derives its expectations from the same list.
//!
//! # Regenerating
//!
//! ```text
//! cargo run --no-default-features --features desktop --example export_control_svgs
//! ```
//!
//! `tools/check_svg_snapshots.sh` runs exactly that and requires the result to match the
//! committed files byte for byte, so a change to any control's drawing appears as a diff
//! in review (rule #106).

#![cfg(all(not(feature = "mini"), not(target_arch = "wasm32")))]

use rust_widgets::theme::{theme_test_guard, AppearanceMode};
use rust_widgets::widget::census::{install_preset_appearances, CENSUS_RECT, CENSUS_TEXT};
use rust_widgets::widget::svg::render_widget_to_svg_on;
use rust_widgets::widget::{draw_bridge::draw_of, WidgetFactory};
use std::fs;
use std::path::Path;

/// Where the snapshots live, relative to the crate root.
const OUTPUT_DIR: &str = "snapshots/svg";

/// The marker every generated SVG carries.
///
/// Rule #106 asks for snapshots that a gate can recognise as **generated**: without it a
/// "skip non-generated files" check would treat these as hand-written artifacts and could
/// legitimately skip them, which is the silent-coverage loss the rule is about. The
/// `tools/check_generated_sources.sh` machinery looks for this shape.
const GENERATED_MARKER: &str = "<!-- GENERATED by examples/export_control_svgs.rs -->";

/// The controls that have a reviewable **interactive state** beyond the two appearances.
///
/// # Why this is a list and not per-control code
///
/// The snapshot set is derived from this table, so "which files exist" and "which states were
/// exercised" cannot disagree. A per-control `if` in the loop below would put the two facts in
/// different places, and the file's existence would stop being evidence that anything looked at
/// the state.
///
/// Each entry is `(control, suffix, describe)`:
///
/// * `control` — the registry name, i.e. the `<name>` half of the file name;
/// * `suffix` — appended to the file name, so `group_box` + `_checked` is `group_box_checked.svg`;
/// * `describe` — the `<!-- control: … -->` marker's state word, so a reader of one file can tell
///   which state it shows without comparing it against its siblings.
const EXTRA_APPEARANCES: &[(&str, &str, &str)] = &[
    // BLUE23 §4.1: the tick is the only part of a group box a user toggles, and `create_group_box`
    // leaves `checkable` false — so without this the checked appearance is absent from the set.
    ("group_box", "_checked", "checked"),
];

/// Applies the state an extra appearance depicts. Returns `false` when the control does not have
/// the state, which is reported rather than silently skipped.
fn apply_extra_state(
    control: &str,
    suffix: &str,
    widget: &mut dyn rust_widgets::widget::Widget,
) -> bool {
    match (control, suffix) {
        ("group_box", "_checked") => {
            use rust_widgets::widget::container_widgets::groupbox::GroupBox;
            match rust_widgets::widget::capability::coercion::widget_as_mut::<GroupBox>(widget) {
                Some(group_box) => {
                    group_box.set_checkable(true);
                    group_box.set_checked(true);
                    true
                }
                None => false,
            }
        }
        _ => false,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Serialises against any other test or probe that switches the process-wide theme, so
    // this cannot observe a half-switched palette.
    let _guard = theme_test_guard();
    install_preset_appearances();

    let dir = Path::new(OUTPUT_DIR);
    fs::create_dir_all(dir)?;

    let factory = WidgetFactory::new_with_defaults();
    let names = factory.widget_names();

    let mut written = 0usize;
    let mut failed: Vec<String> = Vec::new();
    // Counted so the number of controls that received sample data is *reported* rather than assumed. A
    // control whose fill silently stopped matching its name would otherwise be indistinguishable from one
    // that never had data to begin with.
    let mut filled = 0usize;

    for name in &names {
        for (appearance, suffix) in [(AppearanceMode::Dark, ""), (AppearanceMode::Light, ".light")]
        {
            rust_widgets::theme::global_theme_manager().set_appearance(appearance);
            let Some(mut widget) = factory.create(name, CENSUS_RECT, CENSUS_TEXT) else {
                failed
                    .push(format!("{name}: the registry publishes it but `create` returned None"));
                continue;
            };
            // Give the data-bearing controls their content.
            //
            // # Why here and not in the factory
            //
            // `create` is the production path, so a host that asked for an empty table must get an empty
            // one; the sample data exists for the picture. Applying it after construction and before the
            // first draw is what keeps those two requirements apart. See
            // `widget::sample_fill`'s module docs.
            if rust_widgets::widget::sample_fill::apply(name, widget.as_mut()) {
                filled += 1;
            }

            // The theme the control renders under is applied the way the runtime applies
            // it, so the snapshot shows the colours a user would actually get. Without
            // this the two appearances produced identical drawings — the files existed and
            // proved nothing, which is precisely the failure rule #106 is about.
            //
            // # Why this runs **after** the sample fill, and not before
            //
            // `apply_active_theme` looks the control up by `"<kind>:<state>"`, and the state it
            // asks for is `widget.widget_state()`. Applying the theme first therefore resolved the
            // **constructor's** state, so every `:checked` key in the presets was invisible to the
            // snapshot: `check_box.svg` was drawn checked but painted the *resting* fill, because at
            // theme time the box was still unchecked. A state override the picture cannot show is
            // the same defect as a state the control never reports — the key is declared and
            // carries nothing.
            //
            // The runtime has the same ordering requirement, and for the same reason: a style is
            // resolved *for the state the control is in*. A host that re-applies the theme when a
            // control's state changes (which is what `request_redraw`'s callers do) reaches the same
            // answer this ordering reaches once.
            rust_widgets::theme::apply_theme_to_widget(widget.as_mut());

            write_one(
                dir,
                &mut written,
                name,
                suffix,
                "",
                widget.as_mut(),
                appearance,
                &mut failed,
            )?;
        }

        // The interactive states, one file each. Rendered under the dark appearance: these exist to
        // show a *shape* the default file cannot, and the light/dark question is already answered by
        // the pair above.
        for (control, state_suffix, state_word) in EXTRA_APPEARANCES {
            if control != name {
                continue;
            }
            rust_widgets::theme::global_theme_manager().set_appearance(AppearanceMode::Dark);
            let Some(mut widget) = factory.create(name, CENSUS_RECT, CENSUS_TEXT) else {
                continue;
            };
            rust_widgets::widget::sample_fill::apply(name, widget.as_mut());
            if !apply_extra_state(control, state_suffix, widget.as_mut()) {
                failed.push(format!(
                    "{name}{state_suffix}: the extra appearance names a state this control does not have"
                ));
                continue;
            }
            // After the state, for the same reason the two-appearance loop above applies it there:
            // the state is what the lookup key is built from, so theming first would resolve the
            // constructor's state and this file would show the state's *shape* in the resting
            // colours — which is exactly what it is not for.
            rust_widgets::theme::apply_theme_to_widget(widget.as_mut());
            write_one(
                dir,
                &mut written,
                name,
                state_suffix,
                state_word,
                widget.as_mut(),
                AppearanceMode::Dark,
                &mut failed,
            )?;
        }
    }

    // The index is generated too, so the list of controls cannot drift from the files.
    fs::write(dir.join("README.md"), index(&names))?;

    println!("wrote {written} SVG snapshots for {} controls into {OUTPUT_DIR}/", names.len());
    println!(
        "checked={} skipped=0 failed={} sample-filled={}",
        names.len(),
        failed.len(),
        // Two appearances per control, so the counter is per-export; halved for the control count.
        filled / 2
    );
    if !failed.is_empty() {
        for entry in &failed {
            eprintln!("  {entry}");
        }
        return Err(format!("{} control(s) could not be exported", failed.len()).into());
    }
    Ok(())
}

/// Renders one widget and writes one snapshot file.
///
/// # Why this is extracted
///
/// The two-appearance loop and the extra-appearance loop both need the same four steps: attach
/// the `Draw` view, pick the backdrop, render, write. Keeping one copy is what makes an extra
/// appearance impossible to render *differently* from a default one — the failure mode a second
/// inline copy would invite.
#[allow(clippy::too_many_arguments)]
fn write_one(
    dir: &Path,
    written: &mut usize,
    name: &str,
    suffix: &str,
    state_word: &str,
    widget: &mut dyn rust_widgets::widget::Widget,
    appearance: AppearanceMode,
    failed: &mut Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let Some(drawable) = draw_of(widget) else {
        failed.push(format!("{name}: the widget has no `Draw` implementation"));
        return Ok(());
    };

    // A control that paints no background of its own (a `Label`, a `Separator`) is
    // composited over the surface it would really sit on — the active theme's own
    // background. Filling the frame with a fixed white made exactly those controls
    // unreadable in the snapshot: the dark theme's near-white ink on white showed as a
    // blank rectangle while the light snapshot of the same control looked fine. The
    // difference between the two files was an artefact of the exporter, not of the
    // control, and the snapshots exist to show the control.
    let backdrop = rust_widgets::theme::global_theme_manager()
        .current_theme()
        .map(|active| active.colors.background)
        .unwrap_or(rust_widgets::core::Color::WHITE);
    let body = render_widget_to_svg_on(drawable, CENSUS_RECT, backdrop);
    let document = decorate(&body, name, appearance, state_word);
    fs::write(dir.join(format!("{name}{suffix}.svg")), document)?;
    *written += 1;
    Ok(())
}

/// Wraps the renderer's `<svg …>` body with the provenance comment and a theme label.
///
/// The renderer's own output is left byte-for-byte intact after the opening tag, so a
/// diff of two snapshots is a diff of the drawing rather than of this wrapper.
///
/// `state_word` is empty for a plain appearance and names the state for an extra one, so a reader
/// of `group_box_checked.svg` can tell what it shows without comparing it to its siblings.
fn decorate(body: &str, name: &str, appearance: AppearanceMode, state_word: &str) -> String {
    let label = match appearance {
        AppearanceMode::Light => "light",
        AppearanceMode::Dark => "dark",
    };
    if state_word.is_empty() {
        format!("{GENERATED_MARKER}\n<!-- control: {name} | appearance: {label} -->\n{body}\n")
    } else {
        format!(
            "{GENERATED_MARKER}\n<!-- control: {name} | appearance: {label} | state: {state_word} -->\n{body}\n"
        )
    }
}

/// Builds `snapshots/svg/README.md`, listing every control with both of its files.
///
/// Generated rather than hand-written so a control added to the registry cannot leave the
/// index describing an incomplete set — which is exactly the drift this file would
/// otherwise acquire.
fn index(names: &[&'static str]) -> String {
    let mut out = String::new();
    out.push_str("<!-- GENERATED by examples/export_control_svgs.rs -->\n");
    out.push_str("# Control SVG snapshots (BLUE20 layer 3)\n\n");
    out.push_str(
        "One SVG per control, named by its **canonical name**. Each control appears twice:\n\
         `<name>.svg` is the dark appearance, `<name>.light.svg` the light one.\n\n",
    );
    out.push_str(
        "## Why the pair matters\n\n\
         Two files that are byte-identical mean the control renders the same in both\n\
         appearances, so it does not respond to the theme. The files exist so that is\n\
         visible to a person, not only to the `P3` assertion in\n\
         `tools/check_control_rendering.sh`.\n\n",
    );
    out.push_str(
        "## Regenerating\n\n\
         ```bash\n\
         cargo run --no-default-features --features desktop --example export_control_svgs\n\
         ```\n\n\
         `tools/check_svg_snapshots.sh` regenerates the set and requires it to match the\n\
         committed files byte for byte, so **any** change to a control's drawing shows up as\n\
         a diff. Do not hand-edit these files: a hand-edit is reverted by the next run and\n\
         fails the gate in between.\n\n",
    );
    out.push_str(
        "## Adding a control\n\n\
         1. Register it in `src/widget/capability/registration.rs` (capability + constructor).\n\
         2. Re-run the exporter above; the new control's two files and its index row are\n\
            generated automatically — this list is derived from the registry, never typed.\n\
         3. Run `bash tools/check_svg_snapshots.sh`.\n\n",
    );
    out.push_str(&format!("## Controls ({})\n\n", names.len()));
    out.push_str("| # | Control | Dark | Light |\n|---|---|---|---|\n");
    for (index, name) in names.iter().enumerate() {
        out.push_str(&format!(
            "| {} | `{name}` | `{name}.svg` | `{name}.light.svg` |\n",
            index + 1
        ));
    }
    out
}