feat(renderer): carry interactive widget semantics through scenes

This commit is contained in:
Fini 2026-08-07 01:56:54 +08:00
parent bc70639f94
commit 63198ac4f8
6 changed files with 94 additions and 16 deletions

View file

@ -305,10 +305,8 @@ pub(crate) fn node_payload_to_scene(
.collect(),
hidden: node.hidden,
locked: node.locked,
// Composite-widget props pass through untouched (no `$ref`
// resolution needed — they're already concrete after the
// adapter harvested them from the schema). Drives the
// design-surface static visual painter.
// Widget props are already concrete after the adapter harvested them;
// no `$ref` resolution is needed here.
widget: node.widget.as_ref().map(widget_payload_to_scene),
children,
}
@ -329,6 +327,8 @@ fn widget_payload_to_scene(w: &crate::payload::WidgetPayload) -> SceneWidget {
min: w.min,
max: w.max,
step: w.step,
indeterminate: w.indeterminate,
corner_radius_authored: w.corner_radius_authored,
options: w
.options
.iter()

View file

@ -186,6 +186,23 @@ fn text_input_icons_reach_scene_widget() {
assert_eq!(w.placeholder.as_deref(), Some("you@example.com"));
}
#[test]
fn progress_semantics_reach_scene_widget() {
let state = state_from(
r##"{"version":"1.1","formatVersion":"1.1","id":"x",
"app":{"name":"x","version":"1","id":"x"},
"children":[{"type":"progress","id":"p","width":200,"height":8,
"value":75,"max":100,"indeterminate":true,"cornerRadius":0}]}"##,
);
let scene = editor_state_to_layout_scene(&state);
let progress = scene.active_page().unwrap().find("p").unwrap();
let widget = progress.widget.as_ref().expect("progress SceneWidget");
assert!(widget.indeterminate);
assert!(widget.corner_radius_authored);
assert_eq!(progress.corner_radius, 0.0);
}
#[test]
fn pen_document_to_layout_scene_matches_editor_state_path() {
let src = r##"{"version":"1.1","formatVersion":"1.1","id":"x",

View file

@ -301,6 +301,12 @@ pub struct WidgetPayload {
/// visual doesn't quantize.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<f32>,
/// Progress uses a deterministic unknown-progress segment.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub indeterminate: bool,
/// True when `cornerRadius` was authored, including explicit zero.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub corner_radius_authored: bool,
/// `(value, label)` option rows for select / radio_group / tabs.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<WidgetOption>,

View file

@ -39,7 +39,7 @@ pub(crate) fn text_input_to_payload(n: &TextInputNode) -> NodePayload {
placeholder: n.placeholder.clone(),
leading_icon: n.leading_icon.clone(),
trailing_icon: n.trailing_icon.clone(),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -59,7 +59,7 @@ pub(crate) fn text_area_to_payload(n: &TextAreaNode) -> NodePayload {
placeholder: n.placeholder.clone(),
leading_icon: n.leading_icon.clone(),
trailing_icon: n.trailing_icon.clone(),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -78,7 +78,7 @@ pub(crate) fn select_to_payload(n: &SelectNode) -> NodePayload {
value_str: n.value.clone(),
placeholder: n.placeholder.clone(),
options: select_options(n.options.as_deref()),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -94,7 +94,7 @@ pub(crate) fn switch_to_payload(n: &SwitchNode) -> NodePayload {
p.widget = Some(WidgetPayload {
kind: "switch".into(),
checked: Some(bool_or_expr(n.checked.as_ref())),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -111,7 +111,7 @@ pub(crate) fn checkbox_to_payload(n: &CheckboxNode) -> NodePayload {
kind: "checkbox".into(),
checked: Some(bool_or_expr(n.checked.as_ref())),
label: n.label.clone(),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -130,7 +130,7 @@ pub(crate) fn slider_to_payload(n: &SliderNode) -> NodePayload {
min: n.min.map(|v| v as f32),
max: n.max.map(|v| v as f32),
step: n.step.map(|v| v as f32),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -147,7 +147,7 @@ pub(crate) fn radio_group_to_payload(n: &RadioGroupNode) -> NodePayload {
kind: "radio_group".into(),
value_str: n.value.clone(),
options: select_options(n.options.as_deref()),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -169,7 +169,7 @@ pub(crate) fn number_input_to_payload(n: &NumberInputNode) -> NodePayload {
min: n.min.map(|v| v as f32),
max: n.max.map(|v| v as f32),
step: n.step.map(|v| v as f32),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -186,7 +186,8 @@ pub(crate) fn progress_to_payload(n: &ProgressNode) -> NodePayload {
kind: "progress".into(),
value_num: n.value.as_ref().map(number_or_expr),
max: n.max.map(|v| v as f32),
..Default::default()
indeterminate: n.indeterminate.unwrap_or(false),
..widget_payload(n.corner_radius.as_ref())
});
p
}
@ -203,7 +204,7 @@ pub(crate) fn tabs_to_payload(n: &TabsNode, rects: &BTreeMap<String, [f32; 4]>)
kind: "tabs".into(),
value_str: n.value.clone(),
options: select_options(n.tabs.as_deref()),
..Default::default()
..widget_payload(n.corner_radius.as_ref())
});
p.children = n
.children
@ -215,6 +216,15 @@ pub(crate) fn tabs_to_payload(n: &TabsNode, rects: &BTreeMap<String, [f32; 4]>)
p
}
/// Seed fields shared by every first-class widget without losing the
/// distinction between an absent radius and an explicitly-authored zero.
fn widget_payload(corner_radius: Option<&jian_ops_schema::node::CornerRadius>) -> WidgetPayload {
WidgetPayload {
corner_radius_authored: corner_radius.is_some(),
..Default::default()
}
}
/// `BoolOrExpression` → concrete bool. An unresolved `$expr` reference
/// renders as `false` on the static design surface (the runtime
/// evaluates the binding; design-time shows the off state).

View file

@ -6,7 +6,9 @@ fn assert_child_payload(src: &str, radius: f32, kind: &str) {
let node = &loaded.payload.pages[0].children[0];
assert_eq!(node.corner_radius, radius);
assert_eq!(node.widget.as_ref().expect("widget payload").kind, kind);
let widget = node.widget.as_ref().expect("widget payload");
assert_eq!(widget.kind, kind);
assert!(widget.corner_radius_authored);
}
#[test]
@ -44,3 +46,46 @@ fn number_input_payload_carries_corner_radius() {
"number_input",
);
}
#[test]
fn widget_payload_distinguishes_absent_from_explicit_zero_radius() {
let src = r##"{
"version":"1.0.0",
"pages":[{"id":"p","name":"P","children":[
{"type":"switch","id":"absent","width":40,"height":20},
{"type":"switch","id":"square","width":40,"height":20,"cornerRadius":0}
]}],
"children":[]
}"##;
let parsed = jian_ops_schema::load_str(src).expect("canonical load");
let loaded = pen_document_to_payload(&parsed.value);
let children = &loaded.payload.pages[0].children;
assert!(!children[0].widget.as_ref().unwrap().corner_radius_authored);
assert!(children[1].widget.as_ref().unwrap().corner_radius_authored);
assert_eq!(
(children[0].corner_radius, children[1].corner_radius),
(0.0, 0.0)
);
}
#[test]
fn progress_payload_carries_indeterminate() {
let src = r##"{
"version":"1.0.0",
"pages":[{"id":"p","name":"P","children":[
{"type":"progress","id":"loading","width":160,"height":8,
"value":80,"max":100,"indeterminate":true}
]}],
"children":[]
}"##;
let parsed = jian_ops_schema::load_str(src).expect("canonical load");
let loaded = pen_document_to_payload(&parsed.value);
let widget = loaded.payload.pages[0].children[0]
.widget
.as_ref()
.expect("progress widget payload");
assert!(widget.indeterminate);
assert_eq!(widget.value_num, Some(80.0));
}

2
vendor/jian vendored

@ -1 +1 @@
Subproject commit a4b9415f3c02630e616032b158812390eab97793
Subproject commit af3b7bfe3dbd95baaa5886b43b17f2d70d7fc18f