Skip to content

Commit e88b3d3

Browse files
committed
Complete WidgetKit rendering previews
1 parent 7dbb30b commit e88b3d3

10 files changed

Lines changed: 149 additions & 12 deletions

File tree

Editor/editorfe/src/scriptWidgetAPI.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Shared/ScriptWidgetRuntime/AI/AgentRuntimeBridge.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ final class AgentRuntimeBridge {
105105
let runtime = ScriptWidgetRuntime(package: package, environments: [
106106
"widget-size": size.rawValue,
107107
"widget-param": "",
108+
"widget-rendering-mode": "fullColor",
108109
])
109110

110111
let (element, err) = runtime.executeJSXSyncForWidget(jsx)

Shared/ScriptWidgetRuntime/Widget/Runtime/ScriptWidgetRuntime.swift

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,42 @@ class ScriptWidgetRuntime {
508508
}
509509

510510
extension ScriptWidgetRuntime {
511+
512+
private func resolveWidgetRootElement(_ root: ScriptWidgetRuntimeElement) -> Result<ScriptWidgetRuntimeElement, ScriptWidgetError> {
513+
var current = root
514+
var visited = Set<ObjectIdentifier>()
515+
516+
for _ in 0..<ScriptWidgetRuntimeContract.maximumElementDepth {
517+
guard visited.insert(ObjectIdentifier(current)).inserted else {
518+
return .failure(.resourceLimit("Root component resolution contains a cycle"))
519+
}
520+
521+
if current.tagAsString() == "Fragment" {
522+
let children = current.childrenAsElements()
523+
guard children.count == 1, let child = children.first else {
524+
return .success(current)
525+
}
526+
current = child
527+
continue
528+
}
529+
530+
guard current.tagAsString() == nil,
531+
getTypeOfValue(current.tag) == "function" else {
532+
return .success(current)
533+
}
534+
535+
var argument = current.getProps()
536+
argument["children"] = current.getChildren()
537+
guard let resultValue = current.tag.call(withArguments: [argument]),
538+
resultValue.isObject,
539+
let resolved = resultValue.toObject() as? ScriptWidgetRuntimeElement else {
540+
return .failure(.scriptError("Root custom component must return a ScriptWidget element"))
541+
}
542+
current = resolved
543+
}
544+
545+
return .failure(.resourceLimit("Root component resolution exceeds the maximum element depth"))
546+
}
511547

512548
func executeJSXSyncForWidget(_ JSX: String) -> (ScriptWidgetRuntimeElement? , ScriptWidgetError?) {
513549
let trace = ScriptWidgetRuntimeTrace(operation: "widget")
@@ -580,9 +616,15 @@ extension ScriptWidgetRuntime {
580616

581617
let semaphore = DispatchSemaphore(value: 0)
582618
var resultElement: ScriptWidgetRuntimeElement?
619+
var rootResolutionError: ScriptWidgetError?
583620

584621
let renderWidget:@convention(block) (ScriptWidgetRuntimeElement)->Void = { rootElement in
585-
resultElement = rootElement
622+
switch self.resolveWidgetRootElement(rootElement) {
623+
case .success(let resolved):
624+
resultElement = resolved
625+
case .failure(let error):
626+
rootResolutionError = error
627+
}
586628
semaphore.signal()
587629
}
588630
self.runtimeContext["$render"] = unsafeBitCast(renderWidget, to: JSValue.self)
@@ -692,6 +734,10 @@ extension ScriptWidgetRuntime {
692734
promise(.failure(.scriptException(exceptionInfo)))
693735
return
694736
}
737+
if let rootResolutionError {
738+
promise(.failure(rootResolutionError))
739+
return
740+
}
695741

696742
guard let element = resultElement else {
697743
promise(.failure(.scriptError("Transform result is not Element : \(String(describing: resultElement))")))

Tests/ScriptWidgetRuntimeTests/RuntimeExecutionTests.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,59 @@ final class RuntimeExecutionTests: XCTestCase {
144144
XCTAssertTrue(text.contains("hello-param"), "expected widget-param in output, got: \(text)")
145145
}
146146

147+
func testWidgetRenderingModesReachRuntime() {
148+
for mode in ["fullColor", "accented", "vibrant"] {
149+
let runtime = makeRuntime(environments: [
150+
"widget-size": "medium",
151+
"widget-param": "",
152+
"widget-rendering-mode": mode,
153+
])
154+
let (element, error) = runtime.executeJSXSyncForWidget(
155+
"$render(<text>{$getenv(\"widget-rendering-mode\")}</text>);"
156+
)
157+
XCTAssertNil(error)
158+
XCTAssertEqual(element.map(collectText), mode)
159+
}
160+
}
161+
162+
func testRootCustomComponentIsResolvedBeforeReturning() {
163+
let jsx = """
164+
const Shell = ({children}) => <vstack background="#101014">{children}</vstack>;
165+
const App = () => <Shell><text>Resolved root</text></Shell>;
166+
$render(<App />);
167+
"""
168+
let (element, error) = makeRuntime().executeJSXSyncForWidget(jsx)
169+
XCTAssertNil(error, "unexpected error: \(String(describing: error?.displayMessage))")
170+
XCTAssertEqual(element?.tagAsString(), "vstack")
171+
XCTAssertEqual(element?.getProps()["background"] as? String, "#101014")
172+
XCTAssertEqual(element.map(collectText), "Resolved root")
173+
}
174+
175+
func testSingleChildRootFragmentIsResolved() {
176+
let (element, error) = makeRuntime().executeJSXSyncForWidget(
177+
"$render(<><vstack background=\"navy\"><text>One root</text></vstack></>);"
178+
)
179+
XCTAssertNil(error)
180+
XCTAssertEqual(element?.tagAsString(), "vstack")
181+
XCTAssertEqual(element?.getProps()["background"] as? String, "navy")
182+
}
183+
184+
func testMultiChildRootFragmentRemainsAFragment() {
185+
let (element, error) = makeRuntime().executeJSXSyncForWidget(
186+
"$render(<><text>One</text><text>Two</text></>);"
187+
)
188+
XCTAssertNil(error)
189+
XCTAssertEqual(element?.tagAsString(), "Fragment")
190+
XCTAssertEqual(element?.childrenAsElements().count, 2)
191+
}
192+
193+
func testInvalidRootCustomComponentReturnIsRejected() {
194+
let (_, error) = makeRuntime().executeJSXSyncForWidget(
195+
"const App = () => 'not an element'; $render(<App />);"
196+
)
197+
XCTAssertTrue(error?.displayMessage.contains("must return a ScriptWidget element") ?? false)
198+
}
199+
147200
func testConcurrentRuntimesKeepEnvironmentStateIsolated() {
148201
let lock = NSLock()
149202
var rendered: [Int: String] = [:]

docs/scriptwidget-runtime-api.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -790,9 +790,8 @@ Interactive widget toggle. A declared actionID requires a package-relative stora
790790

791791
## Environment values
792792

793-
- `widgetFamily`
794-
- `scriptName`
795-
- `scriptParameter`
793+
- `widget-size`
794+
- `widget-param`
796795
- `widget-rendering-mode`
797796
- `live-activity-state`
798797
- `live-activity-surface`

docs/widgetkit-modern-features.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ ScriptWidget packages can use the current WidgetKit presentation, interaction, L
66

77
Add `systemExtraLargePortrait` to `supportedFamilies` for the tall extra-large family. At runtime, `$getenv("widget-size")` returns `extraLargePortrait`.
88

9-
Use `$getenv("widget-rendering-mode")` to adapt to `fullColor`, `accented`, or `vibrant` rendering. Studio exposes the same three preview modes. Set `widgetAccentable={true}` on a component to opt its content into the accent group. Images additionally accept `accentedRenderingMode="accented"`, `"desaturated"`, `"accentedDesaturated"`, or `"fullColor"`.
9+
Use `$getenv("widget-rendering-mode")` to adapt to `fullColor`, `accented`, or `vibrant` rendering. The iOS editor and macOS Studio expose the same three rendering environments so conditional script branches can be tested. These editor canvases do not reproduce WidgetKit's final system tint pixel-for-pixel; verify the final appearance in a real widget. Set `widgetAccentable={true}` on a component to opt its content into the accent group. Images additionally accept `accentedRenderingMode="accented"`, `"desaturated"`, `"accentedDesaturated"`, or `"fullColor"`.
10+
11+
The root element's `background` is promoted to WidgetKit's container background on supported systems, including when the root is returned through custom components or a single-child Fragment. A Fragment with multiple top-level children has no unique container; wrap those children in one stack when the widget needs a removable container background.
1012

1113
## Live Activities
1214

iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewDataObject.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class ScriptCodePreviewDataObject : ObservableObject {
1313
let model: ScriptModel
1414
var widgetSizeType: Int
1515
var scriptParameter: String
16+
var widgetRenderingMode: String
1617

1718
@Published var rootElement : ScriptWidgetRuntimeElement
1819
@Published var previewStatus : String
@@ -27,11 +28,12 @@ class ScriptCodePreviewDataObject : ObservableObject {
2728
private var previewGeneration = 0
2829
private var pendingLayoutWorkItem: DispatchWorkItem?
2930

30-
init(model: ScriptModel, filePath: URL, widgetSizeType: Int, scriptParameter: String) {
31+
init(model: ScriptModel, filePath: URL, widgetSizeType: Int, scriptParameter: String, widgetRenderingMode: String = "fullColor") {
3132
self.model = model
3233
self.filePath = filePath
3334
self.widgetSizeType = widgetSizeType
3435
self.scriptParameter = scriptParameter
36+
self.widgetRenderingMode = widgetRenderingMode
3537

3638
self.previewQueue = DispatchQueue(label: "preview-queue", qos: .default)
3739
self.previewStatus = "Initializing"
@@ -59,6 +61,11 @@ class ScriptCodePreviewDataObject : ObservableObject {
5961

6062
self.layoutElements()
6163
}
64+
func changeWidgetRenderingMode(_ mode: String) {
65+
self.widgetRenderingMode = mode
66+
67+
self.layoutElements()
68+
}
6269
func changeFile(_ filePath: URL) {
6370
self.filePath = filePath
6471

@@ -154,6 +161,7 @@ class ScriptCodePreviewDataObject : ObservableObject {
154161
let runtime = ScriptWidgetRuntime(package: self.model.package, environments: [
155162
"widget-size": widgetSizeString,
156163
"widget-param": self.scriptParameter,
164+
"widget-rendering-mode": self.widgetRenderingMode,
157165
])
158166

159167
let result = runtime.executeJSXSyncForWidget(JSX)

iOS/ScriptWidget/View/CodeEditor/Preview/ScriptCodePreviewView.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ struct ScriptCodePreviewView: View {
1616

1717
@State private var scriptParameter = ""
1818
@State private var scriptParameterApplied = ""
19+
@State private var widgetRenderingMode = "fullColor"
1920
@State private var outputTab = 0
2021
@FocusState private var scriptParameterIsFocused: Bool
2122

@@ -59,6 +60,7 @@ struct ScriptCodePreviewView: View {
5960
model: state.model,
6061
filePath: state.filePath,
6162
scriptParameter: scriptParameterApplied,
63+
widgetRenderingMode: widgetRenderingMode,
6264
isDebugMode: isDebugMode
6365
)
6466
} else {
@@ -175,6 +177,16 @@ struct ScriptCodePreviewView: View {
175177
Toggle(isOn: $isDebugMode) {
176178
Label("Debug Borders", systemImage: "square.dashed")
177179
}
180+
181+
Picker("Rendering Environment", selection: $widgetRenderingMode) {
182+
Text("Full Color").tag("fullColor")
183+
Text("Accented").tag("accented")
184+
Text("Vibrant").tag("vibrant")
185+
}
186+
.pickerStyle(.segmented)
187+
.onChange(of: widgetRenderingMode) { value in
188+
state.changeWidgetRenderingMode(value)
189+
}
178190

179191
HStack {
180192
TextField("Parameter", text: $scriptParameter)
@@ -206,6 +218,7 @@ private struct ScriptCodeAllSizesPreview: View {
206218
let model: ScriptModel
207219
let filePath: URL
208220
let scriptParameter: String
221+
let widgetRenderingMode: String
209222
let isDebugMode: Bool
210223

211224
var body: some View {
@@ -217,6 +230,7 @@ private struct ScriptCodeAllSizesPreview: View {
217230
filePath: filePath,
218231
sizeType: sizeType,
219232
scriptParameter: scriptParameter,
233+
widgetRenderingMode: widgetRenderingMode,
220234
isDebugMode: isDebugMode
221235
)
222236
}
@@ -233,17 +247,20 @@ private struct ScriptCodePreviewCard: View {
233247
@StateObject private var state: ScriptCodePreviewDataObject
234248
let sizeType: Int
235249
let scriptParameter: String
250+
let widgetRenderingMode: String
236251
let isDebugMode: Bool
237252

238-
init(model: ScriptModel, filePath: URL, sizeType: Int, scriptParameter: String, isDebugMode: Bool) {
253+
init(model: ScriptModel, filePath: URL, sizeType: Int, scriptParameter: String, widgetRenderingMode: String, isDebugMode: Bool) {
239254
self.sizeType = sizeType
240255
self.scriptParameter = scriptParameter
256+
self.widgetRenderingMode = widgetRenderingMode
241257
self.isDebugMode = isDebugMode
242258
_state = StateObject(wrappedValue: ScriptCodePreviewDataObject(
243259
model: model,
244260
filePath: filePath,
245261
widgetSizeType: sizeType,
246-
scriptParameter: scriptParameter
262+
scriptParameter: scriptParameter,
263+
widgetRenderingMode: widgetRenderingMode
247264
))
248265
}
249266

@@ -271,6 +288,9 @@ private struct ScriptCodePreviewCard: View {
271288
.onChange(of: scriptParameter) { value in
272289
state.changeWidgetParameter(value)
273290
}
291+
.onChange(of: widgetRenderingMode) { value in
292+
state.changeWidgetRenderingMode(value)
293+
}
274294
}
275295
}
276296

macOS/ScriptWidgetMac/Editor/EditorPanelView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ private struct WidgetConfigurationView: View {
291291
Text(mode.title).tag(mode)
292292
}
293293
}
294-
Picker("Rendering", selection: $configuration.renderingMode) {
294+
Picker("Rendering Environment", selection: $configuration.renderingMode) {
295295
ForEach(StudioPreviewRenderingMode.allCases) { mode in
296296
Text(mode.title).tag(mode)
297297
}

macOS/ScriptWidgetMac/Editor/Preview/PreviewView.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ struct PreviewView: View {
359359
}
360360

361361

362-
Picker("Rendering", selection: $configuration.renderingMode) {
362+
Picker("Rendering Environment", selection: $configuration.renderingMode) {
363363
ForEach(StudioPreviewRenderingMode.allCases) { mode in
364364
Text(mode.title).tag(mode)
365365
}
@@ -401,6 +401,7 @@ struct PreviewView: View {
401401
family: family,
402402
scriptModel: scriptModel,
403403
scriptParameter: scriptParameterApplied,
404+
renderingMode: configuration.renderingMode,
404405
isDebugMode: configuration.debugMode
405406
)
406407
}
@@ -491,6 +492,7 @@ private struct StudioPreviewTile: View {
491492
let family: StudioPreviewFamily
492493
let scriptModel: ScriptModel
493494
let scriptParameter: String
495+
let renderingMode: StudioPreviewRenderingMode
494496
let isDebugMode: Bool
495497

496498
@StateObject private var data: ScriptCodeRunnerDataObject
@@ -499,16 +501,19 @@ private struct StudioPreviewTile: View {
499501
family: StudioPreviewFamily,
500502
scriptModel: ScriptModel,
501503
scriptParameter: String,
504+
renderingMode: StudioPreviewRenderingMode,
502505
isDebugMode: Bool
503506
) {
504507
self.family = family
505508
self.scriptModel = scriptModel
506509
self.scriptParameter = scriptParameter
510+
self.renderingMode = renderingMode
507511
self.isDebugMode = isDebugMode
508512
_data = StateObject(wrappedValue: ScriptCodeRunnerDataObject(
509513
file: scriptModel.package,
510514
widgetSizeType: family.rawValue,
511-
scriptParameter: scriptParameter
515+
scriptParameter: scriptParameter,
516+
renderingMode: renderingMode
512517
))
513518
}
514519

@@ -545,6 +550,9 @@ private struct StudioPreviewTile: View {
545550
.onChange(of: scriptParameter) { _, value in
546551
data.changeWidgetParameter(value)
547552
}
553+
.onChange(of: renderingMode) { _, value in
554+
data.changeRenderingMode(value)
555+
}
548556
}
549557
}
550558

0 commit comments

Comments
 (0)