From 63198ac4f89be48757b6a2cb5abf8c498ce9d4ce Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 7 Aug 2026 01:56:54 +0800 Subject: [PATCH] feat(renderer): carry interactive widget semantics through scenes --- crates/op-pen-loader/src/layout_scene.rs | 8 ++-- .../src/layout_scene_tests/tokens_refs.rs | 17 +++++++ crates/op-pen-loader/src/payload.rs | 6 +++ crates/op-pen-loader/src/widget_payload.rs | 30 ++++++++---- .../op-pen-loader/src/widget_payload_tests.rs | 47 ++++++++++++++++++- vendor/jian | 2 +- 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/op-pen-loader/src/layout_scene.rs b/crates/op-pen-loader/src/layout_scene.rs index 285e63b8a..ad95ddd9e 100644 --- a/crates/op-pen-loader/src/layout_scene.rs +++ b/crates/op-pen-loader/src/layout_scene.rs @@ -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() diff --git a/crates/op-pen-loader/src/layout_scene_tests/tokens_refs.rs b/crates/op-pen-loader/src/layout_scene_tests/tokens_refs.rs index ed8681a79..5ade3c4e3 100644 --- a/crates/op-pen-loader/src/layout_scene_tests/tokens_refs.rs +++ b/crates/op-pen-loader/src/layout_scene_tests/tokens_refs.rs @@ -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", diff --git a/crates/op-pen-loader/src/payload.rs b/crates/op-pen-loader/src/payload.rs index 6ead4bf9d..5ad8cd097 100644 --- a/crates/op-pen-loader/src/payload.rs +++ b/crates/op-pen-loader/src/payload.rs @@ -301,6 +301,12 @@ pub struct WidgetPayload { /// visual doesn't quantize. #[serde(default, skip_serializing_if = "Option::is_none")] pub step: Option, + /// 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, diff --git a/crates/op-pen-loader/src/widget_payload.rs b/crates/op-pen-loader/src/widget_payload.rs index aca727e2f..16312a53f 100644 --- a/crates/op-pen-loader/src/widget_payload.rs +++ b/crates/op-pen-loader/src/widget_payload.rs @@ -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) 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) 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). diff --git a/crates/op-pen-loader/src/widget_payload_tests.rs b/crates/op-pen-loader/src/widget_payload_tests.rs index e5053987d..df839d4ee 100644 --- a/crates/op-pen-loader/src/widget_payload_tests.rs +++ b/crates/op-pen-loader/src/widget_payload_tests.rs @@ -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)); +} diff --git a/vendor/jian b/vendor/jian index a4b9415f3..af3b7bfe3 160000 --- a/vendor/jian +++ b/vendor/jian @@ -1 +1 @@ -Subproject commit a4b9415f3c02630e616032b158812390eab97793 +Subproject commit af3b7bfe3dbd95baaa5886b43b17f2d70d7fc18f