rust_widgets 2.8.4

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
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! # T-24: mode 1 and mode 2 must describe the **same** UI
//!
//! BLUE19's DoD:
//!
//! > **模式一致性门禁(T-24)**:同一份 JSON 分别经模式 1 与模式 2,产出**行为等价**
//! > ——断言控件树结构、已发布事件、属性值一致。
//! > **反向注入**:让生成器丢一个控件 → 门禁必须 FAIL。
//!
//! # Why "equivalent" is asserted as three separate facts
//!
//! "The two modes agree" is not testable as one claim. It decomposes into the three things a user
//! can actually observe, and each can fail independently:
//!
//! 1. **Structure** — the same controls in the same parent/child arrangement. A generator that
//!    dropped a node or flattened a level would fail here and nowhere else.
//! 2. **Properties** — the same names with the same values. A generator that emitted a property
//!    as a comment, or in the wrong type, fails here.
//! 3. **Events** — the same published event names are reachable. A generator whose `create_for`
//!    omitted a control would still pass (1) and (2) for the controls it *did* emit.
//!
//! # Why mode 2 is read back from the generated *text*, and mode 1 from the loader
//!
//! Mode 1's answer is the loader's: parse the document, walk the registered tree. Mode 2's answer is
//! the generator's: the tree it wrote into the source. Comparing the loader against the generator
//! directly would compare mode 1 with mode 1's input. Reading mode 2's **emitted structure** is what
//! makes this a cross-mode check rather than a self-consistency check.
//!
//! # Profile mixing
//!
//! The gate also asserts that neither template mentions the other's exclusive API (BLUE19's
//! "profile 串味" requirement): a `mini` output must not contain a `crate::view` path or a
//! `create_*` call, because those are exactly the symbols that do not exist there.

#![cfg(all(feature = "desktop", not(alloc_frugal)))]

use rust_widgets::designer::{generate, GenerationRequest, TargetProfile};
use rust_widgets::json::{JsonLoader, JsonProject};

/// A document with nesting, properties of three scalar kinds, and a published event.
const PROJECT: &str = r#"{
  "window": {
    "id": "root",
    "title": "Consistency",
    "width": 800,
    "height": 600,
    "layout": {
      "type": "vbox",
      "children": [
        { "label": { "id": "heading", "text": "Settings" } },
        { "button": { "id": "save", "text": "Save", "enabled": true,
                      "events": { "clicked": "on_save" } } },
        { "slider": { "id": "volume", "value": 40 } }
      ]
    }
  }
}"#;

fn request(target: TargetProfile) -> GenerationRequest {
    GenerationRequest {
        json: String::from(PROJECT),
        target,
        width: 800,
        height: 600,
        function_name: String::from("build_ui"),
    }
}

/// A structural fingerprint of mode 1's tree: `(widget, path)` for every node, in pre-order.
fn mode1_structure() -> Vec<(String, Vec<usize>)> {
    let project = JsonProject::parse(PROJECT).expect("the document must parse");
    project.walk().map(|node| (node.widget.clone(), node.path.clone())).collect()
}

/// A structural fingerprint of mode 2's tree, recovered from the generated source.
///
/// The generated default template is a chain of `Node::new("name").key("k").child(...)`, so the
/// widget names and their nesting are in the text. Counting them and checking nesting depth is what
/// catches "the generator dropped a control" — the reverse injection BLUE19 requires.
fn mode2_nodes(source: &str) -> Vec<String> {
    source
        .split("Node::new(")
        .skip(1)
        .filter_map(|rest| {
            let rest = rest.strip_prefix('"')?;
            rest.split('"').next().map(String::from)
        })
        .collect()
}

/// A structural fingerprint of mode 2's **stripped** tree, recovered from the generated source.
///
/// # Why the stripped template needs its own fingerprint
///
/// `mode2_nodes` reads `Node::new(..)` builders, which only the default template emits. The stripped
/// template emits `Type::new(..)` constructions instead, so it needs its own reader — and without
/// one, "the two modes agree" would silently only ever have meant "the default template agrees",
/// leaving the target that cannot run mode 1 at all as the unverified half.
///
/// The shapes it reads are the two the template emits:
///
/// * `Type::new(String::from("text"), Rect::new(..))` — a text-bearing constructor;
/// * `Type::new(Rect::new(..))` — a geometry-only constructor.
fn mode2_stripped_nodes(source: &str) -> Vec<String> {
    // Only the binding lines, so a `::new` inside a comment cannot be mistaken for a control.
    source
        .lines()
        .map(str::trim)
        .filter(|line| line.starts_with("let ") && line.contains("::new("))
        .filter_map(|line| {
            let after = line.split("::new(").next()?;
            let type_name = after.rsplit(|c: char| !(c.is_alphanumeric() || c == '_')).next()?;
            if type_name.is_empty() || type_name == "Rect" {
                return None;
            }
            Some(String::from(type_name))
        })
        .collect()
}

/// **Structure**: the stripped template describes the same controls as mode 1, in the same order.
///
/// This is the half the desktop-only `mode2_nodes` reader cannot see. The stripped output is the one
/// a `mini`/`embedded` device actually runs, and it is the one produced by a *different* template — so
/// a control dropped there would otherwise reach the target with no test comparing it against the
/// document the user drew.
#[test]
fn the_stripped_template_agrees_on_the_control_tree() {
    let mode1 = mode1_structure();
    let generated = generate(&request(TargetProfile::Stripped)).expect("generation must succeed");
    let mode2 = mode2_stripped_nodes(&generated.source);

    // The generated `create_for`-style arms use bare type names while mode 1 uses document names, so
    // the comparison is on the *type* each document name maps to. `window` → `Window`, `label` →
    // `Label`, and so on; a name the generator cannot construct is reported rather than skipped.
    let expected: Vec<String> = mode1
        .iter()
        .map(|(widget, _)| rust_widgets::designer::generator::constructor_type_name(widget))
        .map(String::from)
        .collect();

    assert_eq!(
        expected, mode2,
        "the stripped template must describe the same controls in the same order as mode 1.\n\
         mode 2 source:\n{}",
        generated.source
    );
    assert_eq!(
        generated.report.nodes_emitted,
        mode1.len(),
        "the report's node count must match the tree it emitted"
    );
}

/// **Structure**: both modes describe the same controls in the same order.
#[test]
fn both_modes_agree_on_the_control_tree() {
    let mode1 = mode1_structure();
    let generated = generate(&request(TargetProfile::Default)).expect("generation must succeed");
    let mode2 = mode2_nodes(&generated.source);

    let mode1_names: Vec<String> = mode1.iter().map(|(widget, _)| widget.clone()).collect();
    assert_eq!(
        mode1_names, mode2,
        "mode 1 and mode 2 must describe the same controls in the same order.\n\
         mode 2 source:\n{}",
        generated.source
    );
    assert_eq!(
        generated.report.nodes_emitted,
        mode1.len(),
        "the report's node count must match the tree it emitted"
    );
}

/// **Properties**: every scalar property mode 1 reads is accounted for by mode 2.
///
/// # What "accounted for" means, and why it is not "appears as `.prop(..)`"
///
/// The first version of this test asserted a `.prop("name", ..)` for **every** scalar key, which
/// encoded a defect rather than a contract: the JSON loader consumes `width`, `padding`,
/// `background`, `x`, `col`, `stretch`, … through dedicated paths (`apply_geometry_shorthand`,
/// `apply_style_padding`, `apply_hex_color`, `apply_child_placement`), and **no control publishes
/// them as property names**. Emitting them as property writes therefore produced an `ApplyReport`
/// full of `PropertyRefused { UnknownProperty }` on a document mode 1 applies cleanly — and this test
/// called that correct because the substring was present.
///
/// The contract is the one `crate::json::is_loader_owned_key` states: a key the loader owns is
/// **not** a property write, and the generator must record it rather than emit it. `text`/`title`
/// are the exception (the generated `create_for` reads them for the constructor).
#[test]
fn both_modes_agree_on_property_values() {
    let project = JsonProject::parse(PROJECT).expect("the document must parse");
    let generated = generate(&request(TargetProfile::Default)).expect("generation must succeed");

    let mut property_writes = 0usize;
    let mut loader_owned = 0usize;
    for node in project.walk() {
        for (name, value) in node.scalar_properties() {
            let emitted = format!(".prop(\"{name}\"");
            if rust_widgets::json::is_loader_owned_key(&name)
                && !matches!(name.as_str(), "text" | "title")
            {
                // A loader-owned key must NOT be a property write, and must be recorded as handled
                // rather than silently dropped (rule #12).
                assert!(
                    !generated.source.contains(&emitted),
                    "`{name}` on {:?} is loader-owned but was emitted as a property write, which \
                     the control's router refuses with `UnknownProperty`",
                    node.path
                );
                assert!(
                    generated
                        .report
                        .resolved_at_generation
                        .iter()
                        .any(|entry| entry.contains(&format!("`{name}`"))),
                    "`{name}` on {:?} is loader-owned but the report does not say it was handled, so \
                     its value is silently lost: {:?}",
                    node.path,
                    generated.report.resolved_at_generation
                );
                loader_owned += 1;
                continue;
            }

            // A real property must be emitted, name and value together, so a generator that wrote
            // the right name with the wrong value fails here rather than passing on the name alone.
            assert!(
                generated.source.contains(&emitted),
                "`{name}` on {:?} is in the document but not in the generated tree",
                node.path
            );
            if let Some(bool_value) = value.as_bool() {
                assert!(
                    generated.source.contains(&format!("CapabilityValue::Bool({bool_value})")),
                    "`{name}` on {:?} must be emitted with its value",
                    node.path
                );
            }
            if let Some(number) = value.as_i64() {
                assert!(
                    generated.source.contains(&format!("({number})")),
                    "`{name}` on {:?} must be emitted with its value",
                    node.path
                );
            }
            if let Some(text) = value.as_str() {
                assert!(
                    generated.source.contains(text),
                    "`{name}` on {:?} must be emitted with its text",
                    node.path
                );
            }
            property_writes += 1;
        }
    }
    assert!(
        property_writes + loader_owned >= 3,
        "the fixture must exercise several properties (got {property_writes} writes, \
         {loader_owned} loader-owned)"
    );
}

/// **Events**: every handler the document declares is reachable, and every declared
/// `events.<name>` is a name the capability table publishes.
#[test]
fn both_modes_agree_on_the_declared_handlers() {
    let project = JsonProject::parse(PROJECT).expect("the document must parse");
    let factory = rust_widgets::widget::capability::WidgetFactory::new_with_defaults();

    let mut declared = 0usize;
    for node in project.walk() {
        for (event, handler) in node.declared_handlers() {
            assert!(
                factory
                    .event_is_subscribable(
                        &node.widget,
                        &event,
                        &rust_widgets::signal::CustomSignalHub::new()
                    )
                    .is_ok(),
                "`{}` declares `events.{event}`, which its capability does not publish; the \
                 handler `{handler}` could never run",
                node.widget
            );
            declared += 1;
        }
    }
    assert_eq!(declared, 1, "the fixture declares exactly one wire");

    // Mode 1 must also reach it: the loader accepts the document and registers the control.
    let layout = JsonLoader::load(PROJECT).expect("mode 1 must accept the document");
    assert!(
        layout.id("save").is_some(),
        "the control carrying the wire must be registered by mode 1"
    );
}

/// **Profile purity**: a `mini` output must not name anything `mini` does not have.
///
/// This is BLUE19's "串味" requirement. It is checked on the text **in addition to** compiling for
/// real, because a text check names the offending symbol while a compiler error only says where it
/// stopped. (`tests/generator_output_compiles_test.rs` is the compile half.)
#[test]
fn the_stripped_output_names_nothing_the_stripped_profile_lacks() {
    let generated = generate(&request(TargetProfile::Stripped)).expect("generation must succeed");
    let source = &generated.source;

    for forbidden in [
        // `crate::view` is `declarative_view`; `mini`/`embedded` do not compile it.
        "rust_widgets::view",
        // `crate::json` is `full_widgets`; absent on both stripped profiles.
        "rust_widgets::json",
        // The `create_*` family is `cfg(not(alloc_frugal))` — 118 of them.
        "create_button",
        "create_label",
        "create_slider",
        // `widget::runtime` is `cfg(not(alloc_frugal))`.
        "widget::runtime",
        // The JSON loader and the factory are `full_widgets`.
        "WidgetFactory",
        "JsonLoader",
    ] {
        assert!(
            !source.contains(forbidden),
            "the stripped output names `{forbidden}`, which the target profile does not compile; \
             this is the cross-profile leak the DoD's 串味 assertion forbids.\nsource:\n{source}"
        );
    }

    // The positive half: the imperative API it *must* use is present.
    assert!(
        source.contains("base_mut()"),
        "the stripped output must construct the tree imperatively (d-3)"
    );
    assert!(
        source.contains("try_add_child"),
        "the stripped output must add children through the capacity-reporting API (d-4)"
    );
}

/// The default output must use the declarative seam, not the imperative one.
#[test]
fn the_default_output_uses_the_declarative_seam() {
    let generated = generate(&request(TargetProfile::Default)).expect("generation must succeed");
    assert!(
        generated.source.contains("rust_widgets::view::Node"),
        "the default template builds a `Node` tree (D7: it reuses the diff engine)"
    );
    assert!(
        generated.source.contains("ViewEngine"),
        "the default template mounts through `ViewEngine`, which is the two modes' shared seam"
    );
    assert!(
        !generated.source.contains("try_add_child"),
        "the default template must not also build imperatively; that would be two trees"
    );
}

/// Capacity is reported per target, and the stripped target's bound is the real one.
///
/// A container with more children than `mini`'s fixed storage holds must be **refused**, because the
/// platform would drop the excess silently and the generated program would look complete.
#[test]
fn a_container_over_capacity_is_reported_for_the_stripped_target_only() {
    use rust_widgets::designer::MINI_CHILD_CAPACITY;

    let mut children = String::new();
    for index in 0..(MINI_CHILD_CAPACITY + 1) {
        if index > 0 {
            children.push(',');
        }
        children.push_str(&format!("{{\"label\":{{\"text\":\"c{index}\"}}}}"));
    }
    let json = format!("{{\"window\":{{\"id\":\"w\",\"children\":[{children}]}}}}");

    let stripped = generate(&GenerationRequest {
        json: json.clone(),
        target: TargetProfile::Stripped,
        width: 400,
        height: 300,
        function_name: String::from("build_ui"),
    })
    .expect("generation must succeed");
    assert!(
        !stripped.report.capacity_overflow.is_empty(),
        "{} children exceeds the stripped target's capacity of {MINI_CHILD_CAPACITY} and must be \
         reported, not emitted: {}",
        MINI_CHILD_CAPACITY + 1,
        stripped.report.summary()
    );

    let default = generate(&GenerationRequest {
        json,
        target: TargetProfile::Default,
        width: 400,
        height: 300,
        function_name: String::from("build_ui"),
    })
    .expect("generation must succeed");
    assert!(
        default.report.capacity_overflow.is_empty(),
        "a heap-allocating target has room for these children; reporting them would be noise"
    );
}