rust_widgets 2.5.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! Where a generated file lives, and how a committed one is recognised as up to date (D7-b-3).
//!
//! # The decision this module implements
//!
//! `blue19.md` §5.1.6 left D7-b-3 open — "生成物是否入库(committed)?". It is now decided:
//! **generated sources are committed, and the designer writes them through the API below.**
//! The reasoning, in the two directions the plan weighed:
//!
//! * **Committing is reviewable.** A generated file in the tree is a diff. A reviewer sees that a
//!   control was added, moved or had a property changed, in the same pull request as the project
//!   document that caused it. A file generated at build time is invisible until it breaks the build.
//! * **Not committing avoids drift.** The cost of committing is that the tree can hold a stale file,
//!   which is why the decision is only safe **with** a regenerate-and-compare gate. That gate is
//!   [`crate::designer::artifact::regenerate_into`] plus `tools/check_generated_sources.sh`, modelled
//!   on `tools/check_abi.sh`'s existing "regenerate → `cmp` → fail on drift" step.
//!
//! Both halves are load-bearing: committing without the gate is how a stale artifact ships, and the
//! gate without committing has nothing to compare against.
//!
//! # Why this is a separate module from `generator`
//!
//! `generator::generate` is a **pure function**: document text in, source text out, no filesystem and
//! no clock. That property is what lets `tests/generator_output_compiles_test.rs` compile its output
//! for real and what makes two runs over one document byte-identical. Writing files is a different
//! concern with a different failure mode (a partial write, a directory that does not exist), so it
//! lives here and the pure core stays pure.
//!
//! # The header marker
//!
//! Every generated file starts with [`GENERATED_MARKER`]. Two things depend on it:
//!
//! 1. the gate refuses to compare a file that does not carry it, so pointing the gate at a
//!    hand-written file fails loudly instead of reporting drift forever;
//! 2. a red build tells the reader **which command regenerates this**, not just that it is stale —
//!    the same reason `tools/check_abi.sh` prints the exact script name in its diagnostic.

use crate::compat::{format, String};
use crate::designer::generator::{self, GenerationRequest, TargetProfile};

/// The first line of every file this crate generates.
///
/// A constant rather than a literal in two places: the writer and the gate both need it, and a gate
/// whose marker string drifted from the writer's would reject every generated file as "not
/// generated" — a failure that looks like the gate is broken rather than like the file is stale.
pub const GENERATED_MARKER: &str =
    "// Generated by the rust_widgets designer. Do not edit by hand.";

/// Where the designer's generated sources live in a consuming project.
///
/// # Why one directory and one file per target
///
/// The two templates emit code that **cannot be compiled together**: the default template names
/// `crate::view`, which a `mini` build does not compile, and the stripped template names nothing from
/// it. Putting both in one file would make every target fail to build. One file per profile keeps the
/// `cfg` decision in `Cargo.toml` (which target compiles which file) rather than in generated `cfg`
/// attributes, which the generator cannot reason about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArtifactPaths;

impl ArtifactPaths {
    /// The directory a consuming project puts generated sources in.
    pub const DIR: &'static str = "src/generated";

    /// The file name for `target`.
    ///
    /// Named by profile rather than by template, because the profile is what a reader builds:
    /// `ui_desktop.rs` is compiled by `--features desktop`, `ui_mini.rs` by `--features mini`.
    /// `desktop`/`tablet`/`mobile` share one file because they emit **identical** code (see
    /// [`TargetProfile`]), so a reader looking for `ui_tablet.rs` is looking for something that
    /// should not exist.
    pub fn file_for(target: TargetProfile) -> &'static str {
        match target {
            TargetProfile::Default => "ui_default.rs",
            TargetProfile::Stripped => "ui_stripped.rs",
        }
    }

    /// The module name the file is declared as, without its extension.
    pub fn module_for(target: TargetProfile) -> &'static str {
        match target {
            TargetProfile::Default => "ui_default",
            TargetProfile::Stripped => "ui_stripped",
        }
    }

    /// The full relative path for `target`.
    pub fn path_for(target: TargetProfile) -> String {
        format!("{}/{}.rs", Self::DIR, Self::module_for(target))
    }
}

/// What a regeneration did, per file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArtifactOutcome {
    /// The file did not exist and was written.
    Created(String),
    /// The file existed with different content and was rewritten.
    Updated(String),
    /// The file already had exactly this content, so nothing was written.
    ///
    /// Distinguished from [`Self::Updated`] so a designer can report "no change" rather than "saved",
    /// and so the gate can treat a run that changed nothing as the expected outcome.
    Unchanged(String),
}

impl ArtifactOutcome {
    /// The file this outcome is about.
    pub fn path(&self) -> &str {
        match self {
            Self::Created(path) | Self::Updated(path) | Self::Unchanged(path) => path,
        }
    }

    /// Whether anything was written.
    pub fn wrote(&self) -> bool {
        !matches!(self, Self::Unchanged(_))
    }

    /// A one-line description for a designer's status area.
    pub fn describe(&self) -> String {
        match self {
            Self::Created(path) => format!("created {path}"),
            Self::Updated(path) => format!("updated {path}"),
            Self::Unchanged(path) => format!("unchanged {path}"),
        }
    }
}

/// One target's generated source, ready to be written.
///
/// Public because the designer needs both halves: the text (to show, or to hand to a build) and the
/// path (where it goes). `GeneratedSource` alone does not say where it belongs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
    /// Where the file goes, relative to the project root.
    pub path: String,
    /// The source text, including [`GENERATED_MARKER`].
    pub source: String,
    /// What the generator could not express, for the caller to report.
    pub report: crate::designer::generator::GenerationReport,
}

/// Generates both targets for one project, without touching the filesystem.
///
/// # Why both, always
///
/// A designer that generated only the target the user is currently previewing would leave the other
/// file stale in the tree, and the regenerate-and-compare gate would fail on a change the user did
/// not make. Producing both together means one project document has exactly one committed state.
///
/// # Errors
///
/// The document's parse error, from the same parser mode 1 uses. A document that parses but contains
/// something the generator cannot express succeeds, with the gap in each artifact's report.
pub fn plan_artifacts(json: &str, width: u32, height: u32) -> Result<Vec<Artifact>, String> {
    let mut artifacts = Vec::with_capacity(2);
    for target in [TargetProfile::Default, TargetProfile::Stripped] {
        let request = GenerationRequest {
            json: String::from(json),
            target,
            width,
            height,
            function_name: String::from("build_ui"),
        };
        let generated = generator::generate(&request)?;
        artifacts.push(Artifact {
            path: ArtifactPaths::path_for(target),
            source: generated.source,
            report: generated.report,
        });
    }
    Ok(artifacts)
}

/// Whether `text` is a file this crate generated.
///
/// Reads the first non-empty line rather than searching the whole text, so a file that merely
/// *mentions* the marker (a test fixture, a document quoting it) is not mistaken for generated
/// output. One of the two callers is a gate that must not be fooled by a quoted string.
pub fn is_generated(text: &str) -> bool {
    text.lines()
        .find(|line| !line.trim().is_empty())
        .is_some_and(|line| line.trim_start().starts_with(GENERATED_MARKER))
}

/// Writes one artifact, reporting whether the content changed.
///
/// # Why the content is compared before writing
///
/// A designer regenerates on every save, and an unconditional write would touch the file's mtime even
/// when nothing changed — which defeats incremental builds downstream and makes "did this run change
/// anything?" unanswerable. Comparing first is what makes [`ArtifactOutcome::Unchanged`] meaningful.
fn write_one(artifact: &Artifact) -> Result<ArtifactOutcome, String> {
    let path = std::path::Path::new(&artifact.path);
    if !artifact.source.contains(GENERATED_MARKER) {
        // Refusing here keeps the marker a property of *everything* this crate writes. A file
        // without it would be skipped by the gate, so the omission would silently disable the
        // drift check for that file.
        return Err(format!(
            "refusing to write {} without the generated marker; every artifact must carry it or \
             the drift gate cannot recognise the file as generated",
            artifact.path
        ));
    }

    match std::fs::read_to_string(path) {
        Ok(existing) if existing == artifact.source => {
            return Ok(ArtifactOutcome::Unchanged(artifact.path.clone()))
        }
        Ok(_) => {}
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            if let Some(parent) = path.parent() {
                std::fs::create_dir_all(parent)
                    .map_err(|error| format!("could not create {}: {error}", parent.display()))?;
            }
            std::fs::write(path, &artifact.source)
                .map_err(|error| format!("could not write {}: {error}", artifact.path))?;
            return Ok(ArtifactOutcome::Created(artifact.path.clone()));
        }
        Err(error) => {
            return Err(format!("could not read {}: {error}", artifact.path));
        }
    }

    std::fs::write(path, &artifact.source)
        .map_err(|error| format!("could not write {}: {error}", artifact.path))?;
    Ok(ArtifactOutcome::Updated(artifact.path.clone()))
}

/// Generates both targets and writes them into `root`, returning what happened to each file.
///
/// This is the entry point a designer calls after the user saves a project. It is deliberately the
/// only writing API: a caller that wrote [`artifact_source`] itself could forget the marker, and the
/// drift gate would then skip the file rather than fail on it.
///
/// # Why `root` is a parameter rather than an ambient working directory
///
/// A designer's process has its own working directory, which is not necessarily the project's. Taking
/// the root explicitly makes the target unambiguous, and makes this testable against a temp
/// directory without changing the process's cwd — which would race with any other test in the binary.
pub fn regenerate_into(
    root: &std::path::Path,
    json: &str,
    width: u32,
    height: u32,
) -> Result<Vec<ArtifactOutcome>, String> {
    let artifacts = plan_artifacts(json, width, height)?;
    let mut outcomes = Vec::with_capacity(artifacts.len());
    for artifact in &artifacts {
        let absolute = root.join(&artifact.path);
        let mut relocated = artifact.clone();
        relocated.path = absolute.to_string_lossy().into_owned();
        outcomes.push(write_one(&relocated)?);
    }
    Ok(outcomes)
}

/// The source text for one target, without writing anything.
///
/// For a preview pane, or for a caller that manages its own file layout. The text always carries
/// [`GENERATED_MARKER`], so a file written from it is recognisable by the gate.
pub fn artifact_source(
    json: &str,
    target: TargetProfile,
    width: u32,
    height: u32,
) -> Result<String, String> {
    let artifacts = plan_artifacts(json, width, height)?;
    let wanted = ArtifactPaths::path_for(target);
    artifacts
        .into_iter()
        .find(|artifact| artifact.path == wanted)
        .map(|artifact| artifact.source)
        .ok_or_else(|| format!("no artifact is produced for {}", target.feature_hint()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compat::Vec;

    const PROJECT: &str = r#"{"window":{"id":"w","title":"T","width":640,"height":480,
        "layout":{"type":"vbox","children":[{"label":{"text":"Hi"}}]}}}"#;

    /// A throwaway project root.
    ///
    /// Keyed by name rather than by process id so a test can call it twice and observe the second
    /// run's outcome — which is how the `Created` → `Unchanged` transition is checked.
    fn temp_root(name: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("rw_artifact_{name}"));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("create the temp root");
        dir
    }

    #[test]
    fn the_marker_is_the_first_line_of_every_artifact() {
        for target in [TargetProfile::Default, TargetProfile::Stripped] {
            let source = artifact_source(PROJECT, target, 640, 480).expect("generation");
            assert!(
                is_generated(&source),
                "a `{:?}` artifact must be recognisable as generated",
                target
            );
        }
    }

    #[test]
    fn a_file_that_merely_mentions_the_marker_is_not_generated() {
        // A test fixture or a document quoting the line must not be mistaken for output.
        let quoted =
            format!("// this doc explains the line `{GENERATED_MARKER}`\nfn main() {{}}\n");
        assert!(!is_generated(&quoted));
        // Nor may a hand-written file that happens to start with a comment.
        assert!(!is_generated("// my hand-written module\nfn main() {}\n"));
        // An empty file is not generated either.
        assert!(!is_generated(""));
    }

    #[test]
    fn leading_blank_lines_do_not_hide_the_marker() {
        let with_blank = format!("\n\n{GENERATED_MARKER}\npub fn build_ui() {{}}\n");
        assert!(is_generated(&with_blank));
    }

    #[test]
    fn both_targets_are_produced_together() {
        let artifacts = plan_artifacts(PROJECT, 640, 480).expect("generation");
        assert_eq!(artifacts.len(), 2, "one project has one committed state per target");
        let paths: Vec<&str> = artifacts.iter().map(|a| a.path.as_str()).collect();
        assert!(paths.contains(&"src/generated/ui_default.rs"));
        assert!(paths.contains(&"src/generated/ui_stripped.rs"));
    }

    #[test]
    fn the_two_targets_never_produce_the_same_file() {
        assert_ne!(
            ArtifactPaths::path_for(TargetProfile::Default),
            ArtifactPaths::path_for(TargetProfile::Stripped),
            "the templates emit mutually un-compilable code, so they cannot share a file"
        );
    }

    #[test]
    fn a_first_write_creates_and_a_second_reports_unchanged() {
        let root = temp_root("first_then_unchanged");
        let first = regenerate_into(&root, PROJECT, 640, 480).expect("first generation");
        assert_eq!(first.len(), 2);
        assert!(
            first.iter().all(|outcome| matches!(outcome, ArtifactOutcome::Created(_))),
            "nothing existed, so both files are created: {first:?}"
        );

        let second = regenerate_into(&root, PROJECT, 640, 480).expect("second generation");
        assert!(
            second.iter().all(|outcome| matches!(outcome, ArtifactOutcome::Unchanged(_))),
            "the same document must produce the same bytes, so nothing is rewritten: {second:?}"
        );
        assert!(!second.iter().any(ArtifactOutcome::wrote), "no write may happen");
    }

    #[test]
    fn a_changed_document_reports_updated() {
        let root = temp_root("updated");
        regenerate_into(&root, PROJECT, 640, 480).expect("first generation");

        let changed = PROJECT.replace("\"Hi\"", "\"Changed\"");
        let outcomes = regenerate_into(&root, &changed, 640, 480).expect("second generation");
        assert!(
            outcomes.iter().any(|outcome| matches!(outcome, ArtifactOutcome::Updated(_))),
            "a changed document must report an update: {outcomes:?}"
        );

        let written = std::fs::read_to_string(root.join("src/generated/ui_default.rs"))
            .expect("the file exists");
        assert!(written.contains("Changed"), "the new text must be on disk");
    }

    #[test]
    fn the_missing_directory_is_created() {
        let root = temp_root("mkdir");
        // Nothing exists under root yet, including `src/`.
        assert!(!root.join(ArtifactPaths::DIR).exists());
        regenerate_into(&root, PROJECT, 640, 480).expect("generation must create its directory");
        assert!(root.join(ArtifactPaths::DIR).is_dir());
    }

    #[test]
    fn a_source_without_the_marker_is_refused_rather_than_written() {
        // Exercised through `write_one`, which is where the check lives: a caller that bypassed the
        // generator and handed over marker-less text must not be able to create a file the drift
        // gate would then skip.
        let root = temp_root("no_marker");
        let artifact = Artifact {
            path: root.join("src/generated/bad.rs").to_string_lossy().into_owned(),
            source: String::from("pub fn build_ui() {}\n"),
            report: Default::default(),
        };
        let error = write_one(&artifact).unwrap_err();
        assert!(error.contains("without the generated marker"), "got: {error}");
        assert!(
            !root.join("src/generated/bad.rs").exists(),
            "a refused write must leave nothing behind"
        );
    }

    #[test]
    fn a_nonexistent_project_root_is_created_rather_than_reported_as_a_read_failure() {
        let root = temp_root("nested").join("deep").join("project");
        regenerate_into(&root, PROJECT, 640, 480).expect("generation must create the whole path");
        assert!(root.join("src/generated/ui_default.rs").is_file());
    }

    #[test]
    fn a_malformed_document_reports_the_parse_error_and_writes_nothing() {
        let root = temp_root("malformed");
        let error = regenerate_into(&root, "not json", 640, 480).unwrap_err();
        assert!(error.contains("could not be parsed"), "got: {error}");
        assert!(
            !root.join(ArtifactPaths::DIR).exists(),
            "a parse failure must not leave a half-written layout behind"
        );
    }

    #[test]
    fn outcomes_describe_themselves_for_a_status_line() {
        assert_eq!(ArtifactOutcome::Created(String::from("a.rs")).describe(), "created a.rs");
        assert_eq!(ArtifactOutcome::Updated(String::from("a.rs")).describe(), "updated a.rs");
        assert_eq!(ArtifactOutcome::Unchanged(String::from("a.rs")).describe(), "unchanged a.rs");
        assert!(ArtifactOutcome::Created(String::from("a.rs")).wrote());
        assert!(!ArtifactOutcome::Unchanged(String::from("a.rs")).wrote());
        assert_eq!(ArtifactOutcome::Updated(String::from("a.rs")).path(), "a.rs");
    }

    #[test]
    fn the_generated_files_are_not_interchangeable_between_targets() {
        // The two templates must not accidentally emit the same text: if they did, one of them is not
        // doing its job and the per-profile file split would be pointless.
        let default = artifact_source(PROJECT, TargetProfile::Default, 640, 480).expect("default");
        let stripped =
            artifact_source(PROJECT, TargetProfile::Stripped, 640, 480).expect("stripped");
        assert_ne!(default, stripped);
        assert!(default.contains("Node::new"), "the default template builds a tree value");
        assert!(stripped.contains("try_add_child"), "the stripped template adds imperatively");
    }

    #[test]
    fn the_artifact_paths_are_directory_scoped() {
        assert!(ArtifactPaths::path_for(TargetProfile::Default).starts_with(ArtifactPaths::DIR));
        assert!(ArtifactPaths::path_for(TargetProfile::Stripped).starts_with(ArtifactPaths::DIR));
        assert_eq!(ArtifactPaths::module_for(TargetProfile::Default), "ui_default");
        assert_eq!(ArtifactPaths::module_for(TargetProfile::Stripped), "ui_stripped");
    }
}