Lists many PRs add to: no written lengths, one file per theme (#7747) (#7898)

* Lists many PRs add to: no written lengths, one file per theme (#7747)

* xtask lists: the fixed-table allowlist in order, each with its own reason

* Lists: the wide-character blocks and the drawing scale presets as slices too (#7747)

* Lists: the drawing units test reads UNITS as a slice (#7747)
This commit is contained in:
raykholo
2026-10-28 21:33:34 +00:00
committed by GitHub
parent 4ad18407dc
commit 8e6a107f6e
58 changed files with 491 additions and 127 deletions
+5
View File
@@ -278,6 +278,11 @@ Some files gain an item in many pull requests at once. They are kept so that two
each add an item only meet when their items are neighbours, which spares everyone a conflict
and a fresh CI run:
- **Growing Rust lists**: use slices (`const ITEMS: &[Item] = &[...];`) without a written
length. `cargo xtask lists` rejects non-test const/static arrays with more than ten lines
of entries, except fixed standard or algorithm tables listed with reasons in `xtask/src/lists.rs`.
Built-in themes need only a JSON file in `assets/styles/ui/themes/`, with a unique nonnegative
integer `order` (the picker order) and its own `provenance` line; keep existing order values.
- **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your
message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci`
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
+1 -1
View File
@@ -1,5 +1,5 @@
//! Built-in model appearances: names, sRGB colours and metalness.
pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [
pub const LIBRARY: &[(&str, [u8; 3], f64)] = &[
("Aluminum - Satin", [204, 208, 214], 1.0),
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
+1 -1
View File
@@ -1,7 +1,7 @@
//! Original illustrative opaque finishes; not measured optical or engineering data.
//! (name, sRGB color, metal fraction, perceptual roughness). IOR is an illustrative 1.5.
//! Separate names preserve the scalar library's appearance and saved assignments.
pub const OPAQUE_FINISHES: [(&str, [u8; 3], f64, f64); 6] = [
pub const OPAQUE_FINISHES: &[(&str, [u8; 3], f64, f64)] = &[
("Steel - Satin PBR", [158, 164, 172], 1.0, 0.4),
("Aluminum - Satin PBR", [204, 208, 214], 1.0, 0.3),
("Copper - Clean PBR", [204, 122, 80], 1.0, 0.25),
+1 -1
View File
@@ -16,7 +16,7 @@ const fn glass(transmission_linear_rgb: [f64; 3], ior: f64, reference_path_mm: f
GlassRecipe { transmission_linear_rgb, dielectric_ior: ior, reference_path_mm, boundary }
}
pub const STARTER_PACK: [VisualPreset; 40] = [
pub const STARTER_PACK: &[VisualPreset] = &[
VisualPreset::opaque(
"starter.wood.oak-honey",
"Wood - Honey Oak Uniform Preview",
+1 -1
View File
@@ -7,7 +7,7 @@ pub const DEFAULT_MATERIAL: &str = "Default";
pub const DEFAULT_DENSITY: f64 = 1.29;
/// Physical materials: name and density (g/cm³).
pub const MATERIALS: [(&str, f64); 14] = [
pub const MATERIALS: &[(&str, f64)] = &[
(DEFAULT_MATERIAL, DEFAULT_DENSITY),
("Steel", 7.85),
("Stainless Steel", 8.0),
+2
View File
@@ -1,4 +1,6 @@
{
"order": 76,
"provenance": "Original black-and-white palette",
"base": "dark",
"colors": {
"accent": "#ffffff",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 75,
"provenance": "Original black-and-white palette",
"base": "light",
"colors": {
"accent": "#000000",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 220,
"provenance": "Maps the Okibi design system",
"base": "light",
"colors": {
"accent": "#c63a00",
@@ -1,4 +1,6 @@
{
"order": 110,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#ffbc48",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 120,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#66ddff",
@@ -1,4 +1,6 @@
{
"order": 200,
"provenance": "Original SolidCraft mapping of Catppuccin (MIT palette)",
"base": "dark",
"colors": {
"accent": "#cba6f7",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 250,
"provenance": "Original mapping of the IBM Color Graphics Adapter palette",
"base": "dark",
"colors": {
"accent": "#55ffff",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 240,
"provenance": "Original warm paper and charcoal palette",
"base": "dark",
"colors": {
"accent": "#e8845e",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 230,
"provenance": "Original warm paper and charcoal palette",
"base": "light",
"colors": {
"accent": "#bf5634",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 90,
"provenance": "Original SolidCraft theme",
"base": "light",
"colors": {
"accent": "#007aff",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 180,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#4aa3d8",
@@ -1,4 +1,6 @@
{
"order": 100,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#4dff79",
@@ -1,4 +1,6 @@
{
"order": 20,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Dark Hard",
"base": "dark",
@@ -1,4 +1,6 @@
{
"order": 30,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Dark Soft",
"base": "dark",
@@ -1,4 +1,6 @@
{
"order": 10,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Dark",
"base": "dark",
@@ -1,4 +1,6 @@
{
"order": 50,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Light Hard",
"base": "light",
@@ -1,4 +1,6 @@
{
"order": 60,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Light Soft",
"base": "light",
@@ -1,4 +1,6 @@
{
"order": 40,
"provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)",
"format": "solidcraft-theme/1",
"name": "Gruvbox Light",
"base": "light",
@@ -1,4 +1,6 @@
{
"order": 170,
"provenance": "Original SolidCraft theme",
"base": "dark",
"colors": {
"accent": "#00bfff",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 160,
"provenance": "Original SolidCraft mapping of Nord (MIT palette)",
"base": "dark",
"colors": {
"accent": "#88c0d0",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 190,
"provenance": "Original SolidCraft mapping of Dracula (MIT palette)",
"base": "dark",
"colors": {
"accent": "#bd93f9",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 210,
"provenance": "Maps the Okibi design system",
"base": "dark",
"colors": {
"accent": "#ff7c50",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 130,
"provenance": "Original SolidCraft mapping of Atom One Dark (MIT palette)",
"base": "dark",
"colors": {
"accent": "#61afef",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 80,
"provenance": "Original SolidCraft theme",
"base": "light",
"colors": {
"accent": "#555599",
@@ -1,4 +1,6 @@
{
"order": 150,
"provenance": "Original SolidCraft mapping of Solarized (MIT palette)",
"base": "light",
"colors": {
"accent": "#268bd2",
@@ -1,4 +1,6 @@
{
"order": 70,
"provenance": "Original SolidCraft theme",
"base": "light",
"colors": {
"accent": "#000080",
+2
View File
@@ -1,4 +1,6 @@
{
"order": 140,
"provenance": "Original SolidCraft mapping of Tokyo Night (MIT palette)",
"base": "dark",
"colors": {
"accent": "#7aa2f7",
+10 -8
View File
@@ -747,7 +747,7 @@ mod tests {
fn stable_ids_categories_and_original_licences_cover_the_requested_pack() {
let mut ids = BTreeSet::new();
let mut names = BTreeSet::new();
for preset in &STARTER_PACK {
for preset in STARTER_PACK {
assert!(preset.id.starts_with("starter.") && preset.id.len() <= 128);
assert!(preset.id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-'));
assert!(ids.insert(preset.id));
@@ -767,14 +767,14 @@ mod tests {
(Category::Solar, 2),
(Category::Regolith, 4),
] {
assert_eq!(STARTER_PACK.iter().filter(|p| p.category == category).count(), count);
assert!(STARTER_PACK.iter().filter(|p| p.category == category).count() >= count);
}
}
#[test]
fn opaque_construction_and_owned_look_roundtrip_preserve_current_response() {
let mut admitted = 0;
for preset in &STARTER_PACK {
for preset in STARTER_PACK {
if let Recipe::Opaque(recipe) = preset.recipe {
let look = preset.opaque_look().unwrap();
assert_eq!(look.opacity, 1.0);
@@ -789,7 +789,7 @@ mod tests {
admitted += 1;
}
}
assert_eq!(admitted, 34);
assert!(admitted >= 34);
}
#[test]
@@ -821,8 +821,10 @@ mod tests {
#[test]
fn legacy_library_and_six_existing_finish_names_are_not_overridden_or_duplicated() {
assert_eq!(crate::appearance::LIBRARY.len(), 20);
for (name, color, opacity) in crate::appearance::LIBRARY {
assert!(!crate::appearance::LIBRARY.is_empty());
let mut names = BTreeSet::new();
for &(name, color, opacity) in crate::appearance::LIBRARY {
assert!(!name.is_empty() && names.insert(name.to_ascii_lowercase()));
let look = crate::appearance::library(name).unwrap();
assert_eq!(look.color, color);
assert_eq!(look.opacity, opacity);
@@ -844,7 +846,7 @@ mod tests {
#[test]
fn uniform_previews_and_planned_resource_scale_are_explicit_and_not_bindings() {
let mut recipes = 0;
for preset in &STARTER_PACK {
for preset in STARTER_PACK {
if let Some(recipe) = preset.future_texture_recipe {
assert!(preset.name.contains("Uniform Preview"));
assert!(preset.preview_note.contains("Uniform opaque preview only"));
@@ -855,7 +857,7 @@ mod tests {
recipes += 1;
}
}
assert_eq!(recipes, 14);
assert!(recipes >= 14);
}
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ fn a_fixed_unit_is_a_length_unit_of_the_expression_table() {
read(&v).map(|d| d.units)
};
for (unit, kind, _) in expr::UNITS {
assert_eq!(units(json!({"fixed": {"unit": unit}})).is_ok(), kind == expr::Kind::Length, "{unit}");
assert_eq!(units(json!({"fixed": {"unit": unit}})).is_ok(), *kind == expr::Kind::Length, "{unit}");
}
assert_eq!(units(json!({"fixed": {"unit": "in"}})).unwrap(), Units::Fixed { unit: "in".into() });
assert_eq!(units(json!("follow_model")).unwrap(), Units::FollowModel);
+3 -3
View File
@@ -127,7 +127,7 @@ impl Kind {
}
/// Units a parameter or an expression may use: name, kind and size (mm or radians).
pub const UNITS: [(&str, Kind, f64); 14] = [
pub const UNITS: &[(&str, Kind, f64)] = &[
("mm", Kind::Length, 1.0),
("cm", Kind::Length, 10.0),
("m", Kind::Length, 1000.0),
@@ -145,7 +145,7 @@ pub const UNITS: [(&str, Kind, f64); 14] = [
];
/// Functions an expression may call (name, signature) — for autocomplete and help.
pub const FUNCTIONS: [(&str, &str); 23] = [
pub const FUNCTIONS: &[(&str, &str)] = &[
("sin", "sin(angle)"),
("cos", "cos(angle)"),
("tan", "tan(angle)"),
@@ -172,7 +172,7 @@ pub const FUNCTIONS: [(&str, &str); 23] = [
];
/// Constants an expression may use.
pub const CONSTANTS: [&str; 2] = ["pi", "PI"];
pub const CONSTANTS: &[&str] = &["pi", "PI"];
/// Kind and size (mm or radians per unit) of a parameter's unit; empty means unit-less.
pub fn unit_info(u: &str) -> Option<(Kind, f64)> {
+1 -1
View File
@@ -717,7 +717,7 @@ fn appearance_library(_s: &mut Session, _p: &Value) -> Result<Value> {
value["category"] = json!(name.split(" - ").next().unwrap_or(name));
Some(value)
}));
for preset in &solidcraft_doc::appearance_pack::STARTER_PACK {
for preset in solidcraft_doc::appearance_pack::STARTER_PACK {
let c = preset.preview_color();
let pbr = preset.opaque_look().ok().and_then(|look| look.pbr);
list.push(json!({"id":preset.id,"name":preset.name,"category":preset.category,"revision":preset.revision,
+1 -1
View File
@@ -308,7 +308,7 @@ mod tests {
fn material_inspection_preserves_legacy_entries_and_reports_unknowns_and_density_separately() {
let mut s = fixture();
let built_in = s.execute("material.list", &json!({})).unwrap();
for (name, density) in solidcraft_doc::MATERIALS {
for &(name, density) in solidcraft_doc::MATERIALS {
let row = built_in["materials"].as_array().unwrap().iter().find(|m| m["name"] == name).unwrap();
assert_eq!(*row, json!({"name":name,"density_g_cm3":density}));
let inspected = s.execute("material.inspect", &json!({"material":name.to_lowercase()})).unwrap();
+1 -1
View File
@@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[
];
/// A family: (id, name, standard).
const FAMILIES: [(&str, &str, &str); 7] = [
const FAMILIES: &[(&str, &str, &str)] = &[
("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"),
("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"),
("hex_nut", "Hex nut", "ISO 4032"),
+20 -14
View File
@@ -215,7 +215,7 @@ fn named_opaque_finishes_reach_the_scene_and_survive_undo_and_reopen_without_cha
("Wood - Oak", [190, 146, 92], 1.0),
("Paint - Gloss White", [246, 246, 244], 1.0),
];
assert_eq!(solidcraft_doc::appearance::LIBRARY, legacy);
assert!(solidcraft_doc::appearance::LIBRARY.len() >= legacy.len());
for (name, color, opacity) in legacy {
let matching: Vec<_> = entries.iter().filter(|entry| entry["name"] == name).collect();
assert_eq!(matching.len(), 1, "legacy row must occur exactly once: {name}");
@@ -224,11 +224,12 @@ fn named_opaque_finishes_reach_the_scene_and_survive_undo_and_reopen_without_cha
assert_eq!(row["opacity"], opacity);
assert!(row.get("pbr").is_none() && row.get("id").is_none(), "legacy scalar meaning changed: {name}");
}
assert_eq!(
solidcraft_doc::appearance::OPAQUE_FINISHES.map(|(name, ..)| name),
for name in
["Steel - Satin PBR", "Aluminum - Satin PBR", "Copper - Clean PBR", "ABS - Molded PBR", "PLA - Smooth PBR", "Wood - Oak Uniform PBR Preview"]
);
for (name, ..) in solidcraft_doc::appearance::OPAQUE_FINISHES {
{
assert!(solidcraft_doc::appearance::OPAQUE_FINISHES.iter().any(|(entry, ..)| *entry == name));
}
for &(name, ..) in solidcraft_doc::appearance::OPAQUE_FINISHES {
let expected = solidcraft_doc::appearance::library(name).unwrap();
let matching: Vec<_> = entries.iter().filter(|entry| entry["name"] == name).collect();
assert_eq!(matching.len(), 1, "finish row must occur exactly once: {name}");
@@ -435,7 +436,7 @@ fn pack_listing_keeps_legacy_rows_and_labels_unavailable_recipes_without_mutatio
("Wood - Oak", [190, 146, 92], 1.0),
("Paint - Gloss White", [246, 246, 244], 1.0),
];
assert_eq!(solidcraft_doc::appearance::LIBRARY, legacy);
assert!(solidcraft_doc::appearance::LIBRARY.len() >= legacy.len());
for (name, color, opacity) in legacy {
let matching: Vec<_> = rows.iter().filter(|row| row["name"] == name).collect();
assert_eq!(matching.len(), 1, "legacy appearance must occur exactly once: {name}");
@@ -452,7 +453,7 @@ fn pack_listing_keeps_legacy_rows_and_labels_unavailable_recipes_without_mutatio
("PLA - Smooth PBR", [110, 160, 222], 0.0, 0.35),
("Wood - Oak Uniform PBR Preview", [190, 146, 92], 0.0, 0.65),
];
assert_eq!(solidcraft_doc::appearance::OPAQUE_FINISHES, finishes);
assert!(finishes.iter().all(|finish| solidcraft_doc::appearance::OPAQUE_FINISHES.contains(finish)));
for (name, color, metallic, roughness) in finishes {
let matching: Vec<_> = rows.iter().filter(|row| row["name"] == name).collect();
assert_eq!(matching.len(), 1, "published finish must occur exactly once: {name}");
@@ -462,12 +463,17 @@ fn pack_listing_keeps_legacy_rows_and_labels_unavailable_recipes_without_mutatio
assert_eq!(row["pbr"], json!({"metallic":metallic,"roughness":roughness,"dielectric_ior":1.5}));
assert!(row.get("id").is_none());
}
assert_eq!(rows.len(), 66);
assert_eq!(rows.iter().filter(|row| row["available"] != false).count(), 60);
assert_eq!(rows.iter().filter(|row| row["available"] != false && row["opacity"] == 1.0).count(), 58);
assert_eq!(rows.iter().filter(|row| row["opacity"] != 1.0).count(), 2);
assert_eq!(rows.iter().filter(|v| v.get("id").is_some()).count(), 40);
assert_eq!(rows.iter().filter(|v| v["available"] == false).count(), 6);
assert!(!rows.is_empty());
let mut names = std::collections::BTreeSet::new();
for row in rows {
let name = row["name"].as_str().unwrap();
assert!(!name.is_empty() && names.insert(name.to_ascii_lowercase()));
}
assert!(rows.iter().filter(|row| row["available"] != false).count() >= 60);
assert!(rows.iter().filter(|row| row["available"] != false && row["opacity"] == 1.0).count() >= 58);
assert!(rows.iter().filter(|row| row["opacity"] != 1.0).count() >= 2);
assert!(rows.iter().filter(|v| v.get("id").is_some()).count() >= 40);
assert!(rows.iter().filter(|v| v["available"] == false).count() >= 6);
for row in rows.iter().filter(|v| v["category"] == "glass") {
assert_eq!(row["available"], false);
assert_eq!(row["opacity"], 1.0);
@@ -608,7 +614,7 @@ fn starter_visual_defaults_and_overrides_keep_saved_physical_cards_independent()
fn starter_names_and_ids_share_admission_before_all_overrides() {
use crate::doc::appearance_pack::{Recipe, STARTER_PACK};
let mut s = pack_session();
for preset in &STARTER_PACK {
for preset in STARTER_PACK {
for selector in [preset.id, preset.name] {
if matches!(preset.recipe, Recipe::Glass(_)) {
for overrides in [
+2 -2
View File
@@ -18,7 +18,7 @@ use solidcraft_render::{
use crate::{Result, Session, params::bad};
const KINDS: [&str; 12] = [
const KINDS: &[&str] = &[
"materials",
"lighting",
"environments",
@@ -45,7 +45,7 @@ fn catalog(kind: &str, command: &str) -> Result<&'static [Value]> {
}
let cached = CATALOGS.get_or_init(|| {
let mut catalogs = BTreeMap::new();
for kind in KINDS {
for &kind in KINDS {
let source = super::library_data::catalog(kind).ok_or_else(|| format!("missing built-in catalog {kind}"))?;
let value: Value = serde_json::from_str(source).map_err(|e| format!("invalid built-in catalog {kind}: {e}"))?;
if !value.get("items").is_some_and(Value::is_array) {
@@ -14,11 +14,14 @@ fn subject() -> Session {
#[test]
fn native_studios_resolve_without_mutation_and_apply_one_complete_undo() {
let entries = super::entries();
assert_eq!(entries.as_array().unwrap().len(), 25);
let hdrs: std::collections::BTreeSet<_> = entries.as_array().unwrap().iter().map(|item| item["hdr"].as_str().unwrap()).collect();
assert_eq!(hdrs.len(), 24, "every authored HDR has a complete curated composition");
for index in 1..=25 {
let id = format!("NATIVE-STUDIO-{index:03}");
let entries = entries.as_array().unwrap();
assert!(!entries.is_empty());
let mut ids = std::collections::BTreeSet::new();
let hdrs: std::collections::BTreeSet<_> = entries.iter().map(|item| item["hdr"].as_str().unwrap()).collect();
assert!(hdrs.len() >= 24, "every authored HDR has a complete curated composition");
for entry in entries {
let id = entry["id"].as_str().unwrap();
assert!(!id.is_empty() && ids.insert(id));
let mut s = subject();
let before = s.doc.to_json();
let undo = s.undo.len();
+5 -5
View File
@@ -390,7 +390,7 @@ impl JsonSchema for StringList {
/// The spellings `Operation::parse` accepts, canonical ones first (it also ignores case, spaces,
/// '_' and '-', which an enum can't say).
const OPERATIONS: [&str; 13] = [
const OPERATIONS: &[&str] = &[
"new",
"join",
"cut",
@@ -447,7 +447,7 @@ impl JsonSchema for OperationParam {
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&OPERATIONS,
OPERATIONS,
"How the feature combines with existing bodies: new, join, cut or intersect. The others are aliases. Matching ignores case, spaces, '_' and '-'.",
)
}
@@ -471,7 +471,7 @@ impl JsonSchema for ExtrudeOperation {
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let mut values = vec!["auto"];
values.extend(OPERATIONS);
values.extend_from_slice(OPERATIONS);
enum_schema(
&values,
"How the extrusion combines with existing bodies: auto (cut into a body, otherwise join; new in an empty component), new, join, cut or intersect. The others are aliases. Matching ignores case, spaces, '_' and '-' (only case for auto).",
@@ -479,7 +479,7 @@ impl JsonSchema for ExtrudeOperation {
}
}
const DIRECTIONS: [&str; 6] = ["positive", "negative", "symmetric", "one_side", "reverse", "flip"];
const DIRECTIONS: &[&str] = &["positive", "negative", "symmetric", "one_side", "reverse", "flip"];
/// Extrude's direction, as `extent_param` reads it (lowercased, not trimmed).
#[derive(Clone, Debug, PartialEq)]
@@ -500,7 +500,7 @@ impl JsonSchema for DirectionParam {
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&DIRECTIONS,
DIRECTIONS,
"Which way to extrude: positive (the default), negative, or symmetric (the distance is the total thickness, split evenly around the start plane). one_side is an alias of positive; reverse and flip of negative. Matching ignores case.",
)
}
+1 -1
View File
@@ -188,7 +188,7 @@ impl Fit {
/// The Unicode blocks of the scripts written in wide characters (Chinese, Japanese, Korean and Yi),
/// with their full-width and vertical forms.
const WIDE: [(u32, u32); 15] = [
const WIDE: &[(u32, u32)] = &[
(0x1100, 0x115F),
(0x2E80, 0x303E),
(0x3041, 0x33FF),
+2 -2
View File
@@ -136,7 +136,7 @@ pub struct Scale {
}
/// ISO 5455's recommended scales, largest first: enlargements, full size, then reductions.
pub const PRESETS: [Scale; 18] = [
pub const PRESETS: &[Scale] = &[
Scale { paper: 50.0, model: 1.0 },
Scale { paper: 20.0, model: 1.0 },
Scale { paper: 10.0, model: 1.0 },
@@ -181,7 +181,7 @@ impl Scale {
if ![size_mm, room_mm].into_iter().all(|v| v.is_finite() && v.x >= 0.0 && v.y >= 0.0) {
return None;
}
PRESETS.into_iter().find(|s| size_mm.x * s.paper <= room_mm.x * s.model && size_mm.y * s.paper <= room_mm.y * s.model)
PRESETS.iter().copied().find(|s| size_mm.x * s.paper <= room_mm.x * s.model && size_mm.y * s.paper <= room_mm.y * s.model)
}
}
+34
View File
@@ -35,6 +35,10 @@ fn main() {
println!("cargo:rerun-if-changed={dir}/logs/HEAD");
}
println!("cargo:rerun-if-changed=build.rs");
if let Err(e) = builtin_themes() {
eprintln!("themes: {e}");
std::process::exit(1);
}
scenario_tests();
if let Err(e) = merged_locales() {
eprintln!("locales: {e}");
@@ -279,3 +283,33 @@ fn icon_data() -> Result<(), String> {
let dest = std::path::Path::new(&std::env::var("OUT_DIR").map_err(|e| e.to_string())?).join("icon_data.rs");
std::fs::write(dest, out).map_err(|e| e.to_string())
}
/// Embed each theme without a shared registry. Only std is needed to collect the files;
/// theme parsing and ordering use the same validation on native and web targets.
fn builtin_themes() -> Result<(), String> {
let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?;
let dir = std::path::Path::new(&manifest).join("../../assets/styles/ui/themes");
println!("cargo:rerun-if-changed={}", dir.display());
let mut files = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? {
let path = entry.map_err(|e| e.to_string())?.path();
if path.extension().is_some_and(|e| e == "json") {
files.push(path);
}
}
files.sort_by(|a, b| a.file_stem().cmp(&b.file_stem()));
let mut out = String::from("/// The theme files shipped with SolidCraft.\nconst BUILTIN: &[(&str, &str)] = &[\n");
for path in files {
let id = path.file_stem().and_then(|s| s.to_str()).ok_or("theme file name is not UTF-8")?;
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
// Keep each file's provenance beside its generated entry, never in a shared header.
let provenance = text
.lines()
.find(|line| line.trim_start().starts_with("\"provenance\":"))
.ok_or_else(|| format!("{}: missing provenance", path.display()))?;
out += &format!(" ({id:?}, include_str!({:?})), // {}\n", path.display().to_string(), provenance.trim());
}
out += "];\n";
let dest = std::path::Path::new(&std::env::var("OUT_DIR").map_err(|e| e.to_string())?).join("builtin_themes.rs");
std::fs::write(dest, out).map_err(|e| e.to_string())
}
+4 -4
View File
@@ -38,7 +38,7 @@ use crate::control::ControlRequest;
/// Areas whose commands only compute the design (no files written, nothing started outside the
/// app), so evaluating them ahead on a copy has no effect but a warm cache.
const AHEAD_AREAS: [&str; 22] = [
const AHEAD_AREAS: &[&str] = &[
"appearance",
"assembly",
"body",
@@ -64,16 +64,16 @@ const AHEAD_AREAS: [&str; 22] = [
];
/// Parameters that name a file or folder: a command given one runs here, as before.
const NAMES_A_FILE: [&str; 8] = ["path", "paths", "file", "files", "dir", "folder", "url", "out"];
const NAMES_A_FILE: &[&str] = &["path", "paths", "file", "files", "dir", "folder", "url", "out"];
/// Commands that change nothing in the design and write a file: they run on a copy.
const ANSWERED_ON_A_COPY: [&str; 2] = ["file.export", "file.save_mesh"];
const ANSWERED_ON_A_COPY: &[&str] = &["file.export", "file.save_mesh"];
/// Commands that only read the design, which can take long on a large one (a measure of every
/// body, an inspection that measures them, #4604; a clearance or interference check of every pair
/// of bodies, minutes on an assembly of 150): they run on a copy too, whatever the preference,
/// since an answer from the design as it was when asked has no order to keep.
const READ_ON_A_COPY: [&str; 4] = ["inspect.measure", "document.inspect", "inspect.clearance", "inspect.interference"];
const READ_ON_A_COPY: &[&str] = &["inspect.measure", "document.inspect", "inspect.clearance", "inspect.interference"];
/// Exports and readings computing at once; more run here.
const MAX_ANSWERING: usize = 4;
+1 -1
View File
@@ -6563,7 +6563,7 @@ mod construct_ask_tests {
use super::*;
use crate::scenario::Harness;
const CONSTRUCT_PICK_COMMANDS: [&str; 15] = [
const CONSTRUCT_PICK_COMMANDS: &[&str] = &[
"construct.axis.through_cylinder",
"construct.axis.normal_to_face",
"construct.axis.two_planes",
+14 -12
View File
@@ -942,7 +942,7 @@ mod tests {
use solidcraft_engine::doc::appearance_pack::STARTER_PACK;
let mut app = app();
let looks = library(&mut app);
for preset in &STARTER_PACK {
for preset in STARTER_PACK {
let matching: Vec<_> = looks.iter().filter(|look| look.preset.as_ref().is_some_and(|p| p.id == preset.id)).collect();
assert_eq!(matching.len(), 1, "actual provider entry missing from UI: {}", preset.id);
let look = matching[0];
@@ -952,15 +952,15 @@ mod tests {
assert_eq!(preset_available(look), preset.is_opaque());
}
let pack: Vec<_> = looks.iter().filter(|look| look.preset.is_some()).collect();
assert_eq!(pack.len(), 40);
assert_eq!(pack.iter().filter(|look| preset_available(look)).count(), 34);
assert_eq!(pack.iter().filter(|look| !preset_available(look)).count(), 6);
for (name, color, opacity) in solidcraft_engine::doc::appearance::LIBRARY {
assert!(pack.len() >= 40);
assert!(pack.iter().filter(|look| preset_available(look)).count() >= 34);
assert!(pack.iter().filter(|look| !preset_available(look)).count() >= 6);
for &(name, color, opacity) in solidcraft_engine::doc::appearance::LIBRARY {
let look = looks.iter().find(|look| look.name == name).unwrap();
assert_eq!((look.color, look.opacity, look.pbr, look.preset.as_ref()), (color, opacity, None, None));
}
// The six published finishes remain independent of starter metadata.
for (name, color, metallic, roughness) in solidcraft_engine::doc::appearance::OPAQUE_FINISHES {
for &(name, color, metallic, roughness) in solidcraft_engine::doc::appearance::OPAQUE_FINISHES {
let matching: Vec<_> = looks.iter().filter(|look| look.name == name).collect();
assert_eq!(matching.len(), 1);
let look = matching[0];
@@ -969,10 +969,12 @@ mod tests {
assert_eq!(look.pbr, Some(PbrParameters::new(metallic, roughness, 1.5).unwrap()));
assert!(look.preset.is_none() && preset_available(look));
}
assert_eq!(looks.len(), 66);
assert_eq!(looks.iter().filter(|look| preset_available(look)).count(), 60);
assert_eq!(looks.iter().filter(|look| preset_available(look) && look.opacity == 1.0).count(), 58);
assert_eq!(looks.iter().filter(|look| look.opacity != 1.0).count(), 2);
assert!(!looks.is_empty());
let mut names = std::collections::BTreeSet::new();
assert!(looks.iter().all(|look| names.insert(look.name.to_ascii_lowercase())));
assert!(looks.iter().filter(|look| preset_available(look)).count() >= 60);
assert!(looks.iter().filter(|look| preset_available(look) && look.opacity == 1.0).count() >= 58);
assert!(looks.iter().filter(|look| look.opacity != 1.0).count() >= 2);
}
#[test]
@@ -1004,7 +1006,7 @@ mod tests {
#[test]
fn manual_edit_of_published_finish_keeps_scalar_override_after_selection_and_editor_reopen() {
for (name, ..) in solidcraft_engine::doc::appearance::OPAQUE_FINISHES {
for &(name, ..) in solidcraft_engine::doc::appearance::OPAQUE_FINISHES {
for reopened in [false, true] {
let mut app = app();
let look = library(&mut app).into_iter().find(|look| look.name == name).unwrap();
@@ -1075,7 +1077,7 @@ mod tests {
let available = rows.iter().filter(|row| row.get("available").and_then(Value::as_bool) != Some(false)).count();
let selectable: Vec<_> = library(&mut h.app).into_iter().filter(preset_available).collect();
assert_eq!(selectable.len(), available);
for preset in &solidcraft_engine::doc::appearance_pack::STARTER_PACK {
for preset in solidcraft_engine::doc::appearance_pack::STARTER_PACK {
assert_eq!(selectable.iter().any(|look| look.name == preset.name), preset.is_opaque());
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ pub(crate) fn joint_choice(name: &str, kind: JointKind) -> String {
/// What a joint origin input accepts (snaps on faces, edges and vertices).
pub const SNAPS: u16 = FACES | EDGES | VERTICES;
/// Commands whose effect is where occurrences sit (previewed on the placed model).
const MOVES: [&str; 12] = [
const MOVES: &[&str] = &[
"joint.create",
"joint.as_built",
"joint.drive",
+1 -1
View File
@@ -14,7 +14,7 @@ use crate::selection::{PROFILES, SKETCH_LINES, SelInput, SelectionExt as _};
use crate::ui_kit::{choice_row, expr_row, hint_row, text_row};
/// The anchors as `frame.member` names them, and their labels.
const ANCHORS: [(&str, &str); 11] = [
const ANCHORS: &[(&str, &str)] = &[
("origin", "Origin"),
("center", "Center"),
("centroid", "Centroid"),
+1 -1
View File
@@ -806,7 +806,7 @@ mod tests {
#[test]
fn navigation_preset_help_reaches_the_catalog() {
let english = Locale::for_language("en");
for preset in &crate::prefs::NAV_PRESETS {
for preset in crate::prefs::NAV_PRESETS {
let id = format!("nav.{}.help", preset.id);
assert_eq!(english.text(&id), preset.help, "{id}");
}
+3 -3
View File
@@ -5,7 +5,7 @@ use solidcraft_engine::doc::physical_material::{Family, MaterialCard, PropertyDa
use crate::{SolveApp, i18n::tr, ui_kit};
const MAX_BYTES: usize = 256 * 1024;
const KINDS: [&str; 9] = [
const KINDS: &[&str] = &[
"density",
"elasticity",
"tensile_strength",
@@ -161,7 +161,7 @@ fn show_card(ui: &mut egui::Ui, card: &MaterialCard, report: &Value) {
let family = serde_json::to_value(card.family).unwrap_or(Value::Null);
value(ui, "family", family.as_str().map(str::to_string), "");
ui_kit::text(ui, tr("ui.material_review.properties"), ui_kit::Text::Report("properties", false));
for kind in KINDS {
for &kind in KINDS {
let label = format!("ui.material_review.{kind}");
let Some(property) = card.properties.iter().find(|p| p.data.kind() == kind) else {
ui_kit::text(ui, &format!("{}: {}", tr(&label), tr("ui.material_review.unknown")), ui_kit::Text::Report(kind, false));
@@ -374,7 +374,7 @@ mod tests {
for catalog in crate::i18n::LOCALES.iter().filter(|c| c.id != "en-GB") {
let path = std::path::Path::new(env!("OUT_DIR")).join("locales").join(format!("{}.json", catalog.id));
let messages: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
for key in keys.into_iter().chain(KINDS) {
for key in keys.into_iter().chain(KINDS.iter().copied()) {
let key = format!("ui.material_review.{key}");
assert!(messages.get(&key).and_then(Value::as_str).is_some_and(|s| !s.is_empty()), "{}: {key}", catalog.id);
}
+10 -9
View File
@@ -33,7 +33,7 @@ use crate::tool_modifiers::ModifierCombo;
use crate::ui_kit;
use crate::viewport::NavMode;
pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"];
pub const UNITS: &[&str] = &["mm", "cm", "m", "in", "ft"];
pub const OPENSCAD_NAV: &str = "openscad";
pub const TOUCHPAD_NAV: &str = "touchpad";
/// A mouse button that can drag the camera.
@@ -100,7 +100,7 @@ use NavButton::{Left, Middle, Right};
use NavMode::{Orbit, Pan, Zoom};
/// The mouse mappings. The first is the default.
pub const NAV_PRESETS: [NavPreset; 10] = [
pub const NAV_PRESETS: &[NavPreset] = &[
NavPreset {
id: "fusion",
label: "Fusion",
@@ -233,7 +233,7 @@ pub fn nav_preset(id: &str) -> &'static NavPreset {
NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0])
}
/// Stable control-channel names; presentation labels may be shorter.
pub const PAGES: [&str; 9] = ["General", "Interface", "Design", "Navigation", "Keyboard Shortcuts", "Sketch", "Graphics", "AI", "Plugins"];
pub const PAGES: &[&str] = &["General", "Interface", "Design", "Navigation", "Keyboard Shortcuts", "Sketch", "Graphics", "AI", "Plugins"];
pub const PAGE_GENERAL: usize = 0;
pub const PAGE_INTERFACE: usize = 1;
pub const PAGE_DESIGN: usize = 2;
@@ -243,7 +243,7 @@ pub const PAGE_SKETCH: usize = 5;
pub const PAGE_GRAPHICS: usize = 6;
pub const PAGE_AI: usize = 7;
pub const PAGE_PLUGINS: usize = 8;
const WINDOW_PAGES: [ui_kit::WindowPage<SolveApp>; 9] = [
const WINDOW_PAGES: &[ui_kit::WindowPage<SolveApp>] = &[
ui_kit::WindowPage { icon: "settings", label: "ui.general", group: None, handle: "prefs:page:General", scroll: true, draw: window_page::<0> },
ui_kit::WindowPage {
icon: "settings_palette",
@@ -434,7 +434,7 @@ fn window_page<const INDEX: usize>(ui: &mut egui::Ui, app: &mut SolveApp) {
page(app, ui, &Tokens::get());
}
pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")];
pub const THEMES: &[(&str, &str)] = &[("dark", "Dark"), ("light", "Light"), ("system", "System")];
pub const MSAA: [u32; 4] = [1, 2, 4, 8];
/// Supersampling factors (#1288): off, 1.5× and 2× the viewport's pixels each way.
pub const SUPERSAMPLING: [f32; 3] = [1.0, 1.5, 2.0];
@@ -1821,7 +1821,7 @@ pub fn show_mm_bare(app: &SolveApp, v: f64) -> String {
}
/// The window system choices (`Prefs::window_system`, #3083).
pub const WINDOW_SYSTEMS: [&str; 3] = ["auto", "wayland", "x11"];
pub const WINDOW_SYSTEMS: &[&str] = &["auto", "wayland", "x11"];
/// winit's issue for dropping files onto a window on native Wayland, which the note links to.
pub const WAYLAND_DROP_ISSUE: &str = "https://github.com/rust-windowing/winit/issues/1881";
@@ -1832,11 +1832,12 @@ pub const WAYLAND_DROP_ISSUE: &str = "https://github.com/rust-windowing/winit/is
fn window_system_row(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let label = |id: &str| crate::i18n::tr(&format!("ui.window_system_{id}")).to_string();
let labels = WINDOW_SYSTEMS.map(label);
let labels: Vec<_> = WINDOW_SYSTEMS.iter().copied().map(label).collect();
let labels: Vec<_> = labels.iter().map(String::as_str).collect();
// The note below says that it applies after a restart, and which one runs now.
let response = ui.allocate_ui_with_layout(egui::vec2(t.form_card_width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| {
let mut selected = WINDOW_SYSTEMS.iter().position(|id| *id == app.preferences.window_system).unwrap_or(0);
if ui_kit::segmented(ui, &mut selected, &labels.each_ref().map(String::as_str), ui_kit::Segments::default())
if ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments::default())
&& let Some(id) = WINDOW_SYSTEMS.get(selected)
{
app.preferences.window_system = (*id).into();
@@ -1978,7 +1979,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
.actions("OK", "Cancel", Some("Apply"));
let window = if capturing { window.keep_open_on_escape() } else { window };
let mut selected = app.prefs_window.section;
let (closed, action) = window.show_pages_with_header(ctx, &mut selected, &WINDOW_PAGES, app, search_header, clear_search, |ui| {
let (closed, action) = window.show_pages_with_header(ctx, &mut selected, WINDOW_PAGES, app, search_header, clear_search, |ui| {
if ui_kit::button(ui, crate::i18n::tr("ui.restore_defaults"), ui_kit::Button::Normal)
.on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page"))
.clicked()
+1 -1
View File
@@ -8,7 +8,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_form(ui, t.space_24 * 7.0 + t.space_2, |ui| {
ui_kit::row(ui, app.tr("ui.units_of_new_designs"), |ui| {
let mut selected = UNITS.iter().position(|u| *u == app.preferences.default_units).unwrap_or(0);
ui_kit::segmented(ui, &mut selected, &UNITS, ui_kit::Segments { intrinsic: true, ..Default::default() });
ui_kit::segmented(ui, &mut selected, UNITS, ui_kit::Segments { intrinsic: true, ..Default::default() });
if let Some(unit) = UNITS.get(selected)
&& app.preferences.default_units != *unit
{
+1 -1
View File
@@ -53,7 +53,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
ui_kit::settings_form(ui, 110.0, |ui| {
ui_kit::row(ui, app.tr("prefs.mouse_mapping"), |ui| {
let picker = ui_kit::dropdown_menu(ui, "pref_nav", nav_preset(&app.preferences.nav).translated_label(), |ui| {
for preset in &NAV_PRESETS {
for preset in NAV_PRESETS {
if ui_kit::list_picker(ui, preset.id, preset.translated_label(), app.preferences.nav == preset.id).clicked() {
app.preferences.nav = preset.id.into();
app.preferences.zoom_reverse = preset.reverse_zoom;
+92 -42
View File
@@ -614,36 +614,8 @@ pub fn export(name: &str, base: Base, t: &Tokens) -> Value {
json!({"format": FORMAT, "name": name, "base": base, "colors": colors, "radii": radii, "borders": borders, "icon_palette": icon_palette})
}
/// The theme files that come with SolidCraft (original files; Gruvbox, Tokyo Night, Solarized, Nord, Nosferatu and Catppuccin map open-source palettes, MIT; Okibi and Akari map the Okibi design system; Clay and Clay Dark are original warm paper and charcoal palettes; CGA keeps to the 16 colours of the IBM Color Graphics Adapter; 1-bit and 1-bit Dark are original black-and-white palettes).
const BUILTIN: [(&str, &str); 27] = [
("gruvbox-dark", include_str!("../../../assets/styles/ui/themes/gruvbox-dark.json")),
("gruvbox-dark-hard", include_str!("../../../assets/styles/ui/themes/gruvbox-dark-hard.json")),
("gruvbox-dark-soft", include_str!("../../../assets/styles/ui/themes/gruvbox-dark-soft.json")),
("gruvbox-light", include_str!("../../../assets/styles/ui/themes/gruvbox-light.json")),
("gruvbox-light-hard", include_str!("../../../assets/styles/ui/themes/gruvbox-light-hard.json")),
("gruvbox-light-soft", include_str!("../../../assets/styles/ui/themes/gruvbox-light-soft.json")),
("solidcraft-95", include_str!("../../../assets/styles/ui/themes/solidcraft-95.json")),
("1-bit", include_str!("../../../assets/styles/ui/themes/1-bit.json")),
("1-bit-dark", include_str!("../../../assets/styles/ui/themes/1-bit-dark.json")),
("platinum-9", include_str!("../../../assets/styles/ui/themes/platinum-9.json")),
("cupertino", include_str!("../../../assets/styles/ui/themes/cupertino.json")),
("green-phosphor", include_str!("../../../assets/styles/ui/themes/green-phosphor.json")),
("amber-phosphor", include_str!("../../../assets/styles/ui/themes/amber-phosphor.json")),
("blueprint", include_str!("../../../assets/styles/ui/themes/blueprint.json")),
("one-dark", include_str!("../../../assets/styles/ui/themes/one-dark.json")),
("tokyo-night", include_str!("../../../assets/styles/ui/themes/tokyo-night.json")),
("solarized-light", include_str!("../../../assets/styles/ui/themes/solarized-light.json")),
("nord", include_str!("../../../assets/styles/ui/themes/nord.json")),
("high-contrast", include_str!("../../../assets/styles/ui/themes/high-contrast.json")),
("graphite", include_str!("../../../assets/styles/ui/themes/graphite.json")),
("nosferatu", include_str!("../../../assets/styles/ui/themes/nosferatu.json")),
("catppuccin-mocha", include_str!("../../../assets/styles/ui/themes/catppuccin-mocha.json")),
("okibi", include_str!("../../../assets/styles/ui/themes/okibi.json")),
("akari", include_str!("../../../assets/styles/ui/themes/akari.json")),
("clay", include_str!("../../../assets/styles/ui/themes/clay.json")),
("clay-dark", include_str!("../../../assets/styles/ui/themes/clay-dark.json")),
("cga", include_str!("../../../assets/styles/ui/themes/cga.json")),
];
// Theme sources are collected at build time; picker order is validated below.
include!(concat!(env!("OUT_DIR"), "/builtin_themes.rs"));
/// The two-colour built-in themes (1-bit and 1-bit Dark): pure black and white, so a few looks
/// other themes tell apart by colour are told apart by text or shape instead.
@@ -652,23 +624,47 @@ pub(crate) fn is_two_colour(id: &str) -> bool {
matches!(id, "1-bit" | "1-bit-dark")
}
/// The built-in themes, read once (one that fails to read is left out; a test reads them all).
/// The built-in themes, validated and ordered once; a test checks every shipped file.
pub fn builtin() -> &'static [ThemeFile] {
static THEMES: OnceLock<Vec<ThemeFile>> = OnceLock::new();
THEMES.get_or_init(|| {
BUILTIN
.iter()
.filter_map(|(id, text)| match parse(text) {
Ok(t) => Some(ThemeFile { id: (*id).to_string(), ..t }),
Err(e) => {
log::error!("built-in theme {id}: {e}");
None
}
})
.collect()
THEMES.get_or_init(|| match read_builtin(BUILTIN) {
Ok(themes) => themes,
Err(e) => {
log::error!("built-in themes: {e}");
Vec::new()
}
})
}
fn read_builtin(sources: &[(&str, &str)]) -> Result<Vec<ThemeFile>, String> {
let mut orders = std::collections::BTreeSet::new();
let mut ids = std::collections::BTreeSet::new();
let mut themes = Vec::new();
for &(id, text) in sources {
if !ids.insert(id) || id.is_empty() {
return Err(format!("empty or duplicate theme id: {id}"));
}
if text.len() > MAX_BYTES {
return Err(format!("theme {id} exceeds the file size limit"));
}
let value: Value = serde_json::from_str(text).map_err(|e| format!("theme {id}: {e}"))?;
let order = value.get("order").and_then(Value::as_u64).ok_or_else(|| format!("theme {id}: order must be a nonnegative integer"))?;
if !orders.insert(order) {
return Err(format!("theme {id}: duplicate order {order}"));
}
if value.get("provenance").and_then(Value::as_str).is_none_or(|s| s.trim().is_empty()) {
return Err(format!("theme {id}: missing provenance"));
}
let theme = from_value(&value).map_err(|e| format!("theme {id}: {e}"))?;
themes.push((order, ThemeFile { id: id.into(), ..theme }));
}
if themes.is_empty() {
return Err("no built-in themes".into());
}
themes.sort_by_key(|(order, _)| *order);
Ok(themes.into_iter().map(|(_, theme)| theme).collect())
}
/// The theme a `theme` preference names: built in, or imported. `None` for dark, light, system
/// or an unknown id.
pub fn find<'a>(id: &str, custom: &'a [ThemeFile]) -> Option<&'a ThemeFile> {
@@ -1060,6 +1056,60 @@ mod tests {
assert_eq!(t.file_image_tint, Color32::WHITE, "{id}");
}
#[test]
fn builtin_order_preserves_the_existing_picker_sequence() {
let original: &[&str] = &[
"gruvbox-dark",
"gruvbox-dark-hard",
"gruvbox-dark-soft",
"gruvbox-light",
"gruvbox-light-hard",
"gruvbox-light-soft",
"solidcraft-95",
"1-bit",
"1-bit-dark",
"platinum-9",
"cupertino",
"green-phosphor",
"amber-phosphor",
"blueprint",
"one-dark",
"tokyo-night",
"solarized-light",
"nord",
"high-contrast",
"graphite",
"nosferatu",
"catppuccin-mocha",
"okibi",
"akari",
"clay",
"clay-dark",
"cga",
];
let themes = read_builtin(BUILTIN).unwrap();
let retained: Vec<_> = themes.iter().map(|t| t.id.as_str()).filter(|id| original.contains(id)).collect();
assert_eq!(retained, original);
assert!(BUILTIN.windows(2).all(|pair| pair[0].0 < pair[1].0), "source entries are sorted by id");
}
#[test]
fn builtin_metadata_rejects_missing_invalid_and_duplicate_order() {
let text =
|order: Value| json!({"format": FORMAT, "name": "Synthetic", "base": "dark", "provenance": "Original", "order": order}).to_string();
let good = text(json!(10));
assert!(read_builtin(&[("a", &good)]).is_ok());
let earlier = text(json!(5));
let ordered = read_builtin(&[("a", &good), ("b", &earlier)]).unwrap();
assert_eq!(ordered.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["b", "a"]);
assert!(read_builtin(&[("a", &good), ("a", &earlier)]).is_err());
assert!(read_builtin(&[("a", &good), ("b", &good)]).is_err());
for order in [Value::Null, json!(-1), json!(1.5), json!("10")] {
assert!(read_builtin(&[("a", &text(order))]).is_err());
}
assert!(read_builtin(&[]).is_err());
}
#[test]
fn built_in_themes_read_and_differ_from_their_base() {
assert_eq!(builtin().len(), BUILTIN.len(), "every built-in theme file reads");
+1 -1
View File
@@ -6,7 +6,7 @@ license.workspace = true
publish = false
[dependencies]
proc-macro2 = { workspace = true }
proc-macro2 = { workspace = true, features = ["span-locations"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
+209 -2
View File
@@ -5,6 +5,7 @@
//!
//! - The message catalogs (`assets/locales/*.json`): one message per line, sorted by id, no
//! duplicate ids, as `serde_json` pretty-prints them.
//! - Growing Rust lists: no written array lengths on more than ten lines of entries.
use std::path::Path;
@@ -37,9 +38,10 @@ pub fn run(root: &Path, fix: bool) -> Result<(), String> {
stale.join(", ")
));
}
check_arrays(root)?;
crate::pending::check_locales(root)?;
crate::pending::check_attribution(root)?;
println!("lists: message catalogs are sorted; pending locale and attribution files fold cleanly");
println!("lists: growing arrays checked; message catalogs sorted; pending locale and attribution files fold cleanly");
Ok(())
}
@@ -56,9 +58,214 @@ pub fn canonical(text: &str) -> Result<String, String> {
Ok(serde_json::to_string_pretty(&Value::Object(map)).map_err(|e| e.to_string())? + "\n")
}
/// Fixed tables whose dimensions describe a standard or an algorithm, rather than a library.
const FIXED_ARRAYS: &[(&str, &str)] = &[
("assets/icons/app/shared/source/app_icon.rs", "FACES"), // Fixed five-face application mark geometry.
("crates/doc/src/component_color.rs", "PALETTE"), // Fixed 24-step hue-wheel algorithm.
("crates/doc/src/eval.rs", "ISO_COARSE"), // ISO metric coarse diameter/pitch pairs.
("crates/doc/src/format.rs", "JSON_STEPS"), // Frozen historical migrations; additions use format/pending.
("crates/engine/src/cmd/features.rs", "TABLE"), // ISO 273 metric bolt clearance-hole dimensions.
("crates/engine/src/cmd/parts.rs", "BEARINGS"), // Standard deep-groove ball bearing dimensions.
("crates/engine/src/cmd/parts.rs", "HEX_BOLT"), // ISO 4017 hex bolt dimensions.
("crates/engine/src/cmd/parts.rs", "HEX_NUT"), // ISO 4032 hex nut dimensions.
("crates/engine/src/cmd/parts.rs", "METRIC"), // Standard metric screw diameters and pitches.
("crates/engine/src/cmd/parts.rs", "SHCS"), // ISO 4762 socket-head cap screw dimensions.
("crates/engine/src/cmd/parts.rs", "WASHER"), // ISO 7089 plain washer dimensions.
("crates/ui-egui/src/dialogs_assembly.rs", "TYPES"), // Joint types; TYPE_LABELS must stay the same length.
("crates/ui-egui/src/dialogs_assembly.rs", "TYPE_LABELS"), // Labels for TYPES, index for index.
("crates/ui-egui/src/space_mouse_calibration.rs", "STEP_PROMPTS"), // Labels for the twelve fixed calibration motions.
("crates/ui-egui/src/space_mouse_calibration.rs", "TRACKBALL_PROMPTS"), // Trackball labels indexed by the same twelve motions.
];
fn test_only(attrs: &[syn::Attribute]) -> bool {
fn requires_test(meta: &syn::Meta) -> bool {
match meta {
syn::Meta::Path(path) => path.is_ident("test"),
syn::Meta::List(list) => {
let Ok(items) = list.parse_args_with(syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated) else {
return false;
};
if list.path.is_ident("all") {
items.iter().any(requires_test)
} else {
list.path.is_ident("any") && !items.is_empty() && items.iter().all(requires_test)
}
}
_ => false,
}
}
attrs.iter().any(|a| a.path().is_ident("test") || (a.path().is_ident("cfg") && a.parse_args::<syn::Meta>().is_ok_and(|m| requires_test(&m))))
}
#[derive(Default)]
struct ArrayGuard {
found: Vec<String>,
}
impl ArrayGuard {
fn array(&mut self, name: &syn::Ident, ty: &syn::Type, expr: &syn::Expr) {
use syn::spanned::Spanned;
fn initializer(expr: &syn::Expr) -> Option<&syn::ExprArray> {
match expr {
syn::Expr::Array(array) => Some(array),
syn::Expr::Paren(paren) => initializer(&paren.expr),
syn::Expr::Group(group) => initializer(&group.expr),
syn::Expr::Block(block) => match block.block.stmts.last()? {
syn::Stmt::Expr(expr, None) => initializer(expr),
_ => None,
},
_ => None,
}
}
if !matches!(ty, syn::Type::Array(_)) {
return;
}
let count = if let Some(array) = initializer(expr) {
let mut lines = std::collections::BTreeSet::new();
for entry in &array.elems {
lines.extend(entry.span().start().line..=entry.span().end().line);
}
lines.len()
} else {
// Macro and computed initializers still carry a written array length.
expr.span().end().line.saturating_sub(expr.span().start().line + 1)
};
if count > 10 {
self.found.push(name.to_string());
}
}
}
impl<'ast> syn::visit::Visit<'ast> for ArrayGuard {
fn visit_item(&mut self, item: &'ast syn::Item) {
let attrs = match item {
syn::Item::Mod(i) => &i.attrs,
syn::Item::Fn(i) => &i.attrs,
syn::Item::Const(i) => &i.attrs,
syn::Item::Static(i) => &i.attrs,
syn::Item::Impl(i) => &i.attrs,
syn::Item::Trait(i) => &i.attrs,
_ => return syn::visit::visit_item(self, item),
};
if !test_only(attrs) {
syn::visit::visit_item(self, item);
}
}
fn visit_item_const(&mut self, item: &'ast syn::ItemConst) {
self.array(&item.ident, &item.ty, &item.expr);
syn::visit::visit_item_const(self, item);
}
fn visit_item_static(&mut self, item: &'ast syn::ItemStatic) {
self.array(&item.ident, &item.ty, &item.expr);
syn::visit::visit_item_static(self, item);
}
fn visit_impl_item(&mut self, item: &'ast syn::ImplItem) {
let attrs = match item {
syn::ImplItem::Const(i) => &i.attrs,
syn::ImplItem::Fn(i) => &i.attrs,
_ => return syn::visit::visit_impl_item(self, item),
};
if !test_only(attrs) {
syn::visit::visit_impl_item(self, item);
}
}
fn visit_impl_item_const(&mut self, item: &'ast syn::ImplItemConst) {
self.array(&item.ident, &item.ty, &item.expr);
syn::visit::visit_impl_item_const(self, item);
}
fn visit_trait_item(&mut self, item: &'ast syn::TraitItem) {
let attrs = match item {
syn::TraitItem::Const(i) => &i.attrs,
syn::TraitItem::Fn(i) => &i.attrs,
_ => return syn::visit::visit_trait_item(self, item),
};
if !test_only(attrs) {
syn::visit::visit_trait_item(self, item);
}
}
fn visit_trait_item_const(&mut self, item: &'ast syn::TraitItemConst) {
if let Some((_, expr)) = &item.default {
self.array(&item.ident, &item.ty, expr);
}
syn::visit::visit_trait_item_const(self, item);
}
}
fn array_violations(source: &str) -> Result<Vec<String>, String> {
use syn::visit::Visit;
let ast = syn::parse_file(source).map_err(|e| e.to_string())?;
if test_only(&ast.attrs) {
return Ok(Vec::new());
}
let mut guard = ArrayGuard::default();
guard.visit_file(&ast);
Ok(guard.found)
}
fn check_arrays(root: &Path) -> Result<(), String> {
fn scan(root: &Path, dir: &Path, failures: &mut Vec<String>) -> Result<(), String> {
for entry in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let kind = entry.file_type().map_err(|e| e.to_string())?;
let name = entry.file_name();
let name = name.to_string_lossy();
if kind.is_dir() {
if !matches!(name.as_ref(), "target" | ".git" | "tests" | "benches") {
scan(root, &path, failures)?;
}
} else if kind.is_file() && path.extension().is_some_and(|e| e == "rs") && name != "tests.rs" && !name.ends_with("_tests.rs") {
let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().replace('\\', "/");
let source = std::fs::read_to_string(&path).map_err(|e| format!("{relative}: {e}"))?;
for name in array_violations(&source).map_err(|e| format!("{relative}: {e}"))? {
if !FIXED_ARRAYS.contains(&(relative.as_str(), name.as_str())) {
failures.push(format!("{relative}: {name}"));
}
}
}
}
Ok(())
}
let mut failures = Vec::new();
for dir in ["apps", "assets", "crates", "tools", "xtask"] {
scan(root, &root.join(dir), &mut failures)?;
}
failures.sort();
if failures.is_empty() {
Ok(())
} else {
Err(format!("use slices without written lengths for growing lists (fixed tables need a reason in FIXED_ARRAYS):\n{}", failures.join("\n")))
}
}
#[cfg(test)]
mod tests {
use super::canonical;
use super::{array_violations, canonical};
#[test]
fn growing_arrays_reject_lengths_and_allow_slices_short_tables_and_test_fixtures() {
let entries = " 0,\n".repeat(11);
let array = format!("const GROWING: [u8; 11] = [\n{entries}];");
assert_eq!(array_violations(&array).unwrap(), ["GROWING"]);
assert_eq!(array_violations(&array.replace("const", "static")).unwrap(), ["GROWING"]);
assert_eq!(array_violations(&format!("const GENERATED: [u8; 11] = entries![\n{entries}];")).unwrap(), ["GENERATED"]);
assert_eq!(array_violations(&format!("impl Catalog {{ {array} }}")).unwrap(), ["GROWING"]);
assert!(array_violations(&array.replace("[u8; 11] = [", "&[u8] = &[ ")).unwrap().is_empty());
assert!(array_violations(&format!("#[cfg(test)] mod tests {{ {array} }}")).unwrap().is_empty());
assert!(array_violations(&format!("#![cfg(test)]\n{array}")).unwrap().is_empty());
assert!(array_violations(&format!("#[cfg(all(test, unix))] mod tests {{ {array} }}")).unwrap().is_empty());
assert_eq!(array_violations(&format!("#[cfg(not(test))] mod production {{ {array} }}")).unwrap(), ["GROWING"]);
assert_eq!(array_violations(&format!("trait Catalog {{ {array} }}")).unwrap(), ["GROWING"]);
assert!(array_violations(&format!("#[test] fn fixture() {{ {array} }}")).unwrap().is_empty());
assert!(array_violations("const SHORT: [u8; 100] = [0; 100];").unwrap().is_empty());
assert!(array_violations(&format!("const TEN: [u8; 10] = [\n{}];", "0,\n".repeat(10))).unwrap().is_empty());
assert!(array_violations(&format!("const TEXT: &str = {array:?};")).unwrap().is_empty());
}
#[test]
fn catalogs_are_sorted_one_per_line_and_a_repeated_id_keeps_its_last_message() {