Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/api-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@ inventory check is temporarily deferred until that inventory recognizes
that renderer. The MyGo, Web, Apple, Android and shared ABI regressions remain
covered separately.

## Unreleased: Apple normalizes all floating wire properties

Apple now normalizes integral JSON numbers to its floating retained value for
every shared `FloatValue` property, not just the previously covered subset.
JSONDecoder can decode `2.0` or `2e0` as an integer; previously, for example,
`{"op":"set-prop","id":4,"property":"letter-spacing","value":2.0}`
rejected the entire batch even though the OCaml producer emitted a valid float.

Existing normalization remains for `item-extent`, `grow`, `anchor-offset`,
`anchor-alignment-offset`, `source-x`, `source-y`, `source-width`, `source-height`,
`value`, `min`, `max`, and `step`.
Newly covered properties are `first-pane-width`, `resize-origin`,
`resize-handle-offset`, `opacity`, `label-opacity`, `letter-spacing`, `inset`,
`inset-top`, `inset-right`, `inset-bottom`, `inset-left`, `hover-opacity`,
`pressed-opacity`, `disabled-opacity`, `width-viewport`, `height-viewport`,
`min-width-viewport`, `max-width-viewport`, `min-height-viewport`,
`max-height-viewport`, `popup-x`, `popup-y`, `available-height`, and
`collision-padding`.

No application migration or JSON rewriting is needed: continue emitting numeric
values, including integral floats such as `letter-spacing: 2.0` or `opacity: 1.0`.
Fractional numbers keep their existing behavior. Boolean and string values are
not converted; integer-only properties are unchanged. Property/kind admission,
finite-value checks, numeric bounds, and atomic batch rollback are unchanged.
The OCaml protocol and generic native ABI are unchanged.

## Unreleased: Android accepts wrapping preserved text

Android's retained text-flow validator now accepts `white-space: "pre-wrap"`,
Expand Down
20 changes: 13 additions & 7 deletions platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1871,15 +1871,21 @@ struct LUIRetainedTree {

extension LUIWireValue {
func normalized(for property: LUIProperty) -> LUIWireValue {
if property == .itemExtent || property == .grow || property == .anchorOffset || property == .anchorAlignmentOffset ||
property == .sourceX || property == .sourceY ||
property == .sourceWidth || property == .sourceHeight ||
property == .progressValue || property == .minValue ||
property == .maxValue || property == .stepValue,
case let .int(value) = self {
guard case .int(let value) = self else { return self }
switch property {
case .itemExtent, .grow, .anchorOffset, .anchorAlignmentOffset,
.sourceX, .sourceY, .sourceWidth, .sourceHeight, .progressValue,
.minValue, .maxValue, .stepValue, .firstPaneWidth,
.resizeOrigin, .resizeHandleOffset, .opacity, .labelOpacity,
.letterSpacing, .inset, .insetTop, .insetRight, .insetBottom, .insetLeft,
.hoverOpacity, .pressedOpacity, .disabledOpacity,
.widthViewport, .heightViewport, .minWidthViewport, .maxWidthViewport,
.minHeightViewport, .maxHeightViewport,
.popupX, .popupY, .availableHeight, .collisionPadding:
return .double(Double(value))
default:
return self
}
return self
}

var intValue: Int? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,48 @@ private let captureAppleEvent: LUIAppleEventCallback = { kind, node, text in
@MainActor
@Suite("LUI SwiftUI backend", .serialized)
struct LUISwiftUIBackendTests {
@Test("floating wire properties admit integral JSON numbers without weakening validation")
func floatingWireNumbers() throws {
let properties: [LUIProperty] = [
.itemExtent, .grow, .anchorOffset, .anchorAlignmentOffset,
.sourceX, .sourceY, .sourceWidth, .sourceHeight, .progressValue,
.minValue, .maxValue, .stepValue, .firstPaneWidth,
.resizeOrigin, .resizeHandleOffset, .opacity, .labelOpacity,
.letterSpacing, .inset, .insetTop, .insetRight, .insetBottom, .insetLeft,
.hoverOpacity, .pressedOpacity, .disabledOpacity,
.widthViewport, .heightViewport, .minWidthViewport, .maxWidthViewport,
.minHeightViewport, .maxHeightViewport,
.popupX, .popupY, .availableHeight, .collisionPadding,
]
for property in properties {
for (json, expected) in [("1", 1.0), ("1.0", 1.0), ("1e0", 1.0), ("0.5", 0.5)] {
let decoded = try JSONDecoder().decode(LUIWireValue.self, from: Data(json.utf8))
let value = decoded.normalized(for: property)
#expect(value == .double(expected), "\(property.rawValue): \(json)")
#expect(value.matches(property, on: .box), "\(property.rawValue): \(json)")
}
for json in ["true", "\"1\""] {
let decoded = try JSONDecoder().decode(LUIWireValue.self, from: Data(json.utf8))
#expect(!decoded.normalized(for: property).matches(property, on: .box))
}
if property != .progressValue {
for number in [Double.nan, .infinity, -.infinity] {
#expect(!LUIWireValue.double(number).normalized(for: property).matches(property, on: .box))
}
}
}
for (property, number): (LUIProperty, Int) in [
(.grow, -1), (.firstPaneWidth, -1), (.stepValue, 0), (.itemExtent, 0),
(.opacity, 2), (.labelOpacity, -1), (.hoverOpacity, 2),
(.pressedOpacity, -1), (.disabledOpacity, 2),
(.widthViewport, 0), (.heightViewport, 2), (.minWidthViewport, 0),
(.maxWidthViewport, 2), (.minHeightViewport, 0), (.maxHeightViewport, 2),
(.availableHeight, -1), (.collisionPadding, -1),
] {
#expect(!LUIWireValue.int(number).normalized(for: property).matches(property, on: .box))
}
}

@Test("removed line-break nodes reject the batch without changing the tree")
func removedLineBreakNode() throws {
let backend = LUIAppleBackend()
Expand Down
Loading